You use Claude Code every day. It writes solid code, refactors, generates tests. But between tasks, who checks that the linter passes? That tests still run? That no API key slipped into a commit?
Nobody. Or rather: you, when you remember. And you don’t always remember.
Hooks solve this. They are shell commands that run automatically at specific moments in Claude Code’s lifecycle. No need to think about it: the safety net is always on.
What is a hook?
A hook is a command that triggers when Claude Code performs an action. Four event types are available:
PreToolUse: before Claude executes a tool (Bash, Edit, Write…)PostToolUse: after a tool executesNotification: when Claude sends a notificationStop: when Claude finishes a turn
Configuration lives in .claude/settings.json (project scope, committed to the repo) or ~/.claude/settings.json (global scope, personal). Here is the JSON structure:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "your-script.sh"
}
]
}
]
}
}
The hook receives context via stdin: a JSON object containing the tool name, input parameters, and session info. The exit code determines behavior: exit 0 = proceed, exit 2 = block the action. Anything written to stderr is sent back to Claude as feedback. It can then correct its action.
5 concrete hooks
Hook 1: Block dangerous commands
A rm -rf / or a git push --force to main happens. Especially when an autonomous agent is typing the commands. This hook intercepts Bash commands before execution and blocks dangerous patterns.
Event: PreToolUse | Matcher: Bash
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 -c \"\nimport sys, json\ndata = json.load(sys.stdin)\ncmd = data.get('tool_input', {}).get('command', '')\nblocked = ['rm -rf /', 'git push --force', 'git push -f', 'DROP TABLE', 'DROP DATABASE', ':(){:|:&};:']\nfor b in blocked:\n if b in cmd:\n print(f'BLOCKED: dangerous command detected ({b})', file=sys.stderr)\n sys.exit(2)\n\""
}
]
}
]
}
}
If the command matches a blocked pattern, the script returns exit 2. Claude receives the error message and reformulates its command.
Hook 2: Auto-lint after every edit
Every time Claude modifies a file, ESLint runs on it automatically. If the linter finds errors, Claude sees them in stderr and fixes immediately.
Event: PostToolUse | Matcher: Edit
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'FILE=$(echo $CLAUDE_FILE_PATH); if [[ \"$FILE\" == *.js || \"$FILE\" == *.ts || \"$FILE\" == *.tsx ]]; then npx eslint --fix \"$FILE\" 2>&1; fi'"
}
]
}
]
}
}
Adapt the command to your stack: ruff check for Python, rubocop -a for Ruby, gofmt for Go. The principle stays the same.
Hook 3: Check for secrets before commit
This hook intercepts any git commit command and scans staged files for suspicious patterns: API keys, tokens, hardcoded passwords.
Event: PreToolUse | Matcher: Bash
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 -c \"\nimport sys, json, subprocess, re\ndata = json.load(sys.stdin)\ncmd = data.get('tool_input', {}).get('command', '')\nif 'git commit' not in cmd:\n sys.exit(0)\nresult = subprocess.run(['git', 'diff', '--cached', '--diff-filter=ACM'], capture_output=True, text=True)\npatterns = [r'AKIA[0-9A-Z]{16}', r'sk-[a-zA-Z0-9]{20,}', r'ghp_[a-zA-Z0-9]{36}', r'password\\s*=\\s*[\\\"\\'][^\\\"\\']+']\nfor p in patterns:\n matches = re.findall(p, result.stdout)\n if matches:\n print(f'BLOCKED: potential secret detected in staged files: {matches[0][:20]}...', file=sys.stderr)\n sys.exit(2)\n\""
}
]
}
]
}
}
This is not a replacement for gitleaks or trufflehog in your CI. It is a first line of defense that catches obvious mistakes before they leave your machine.
Hook 4: Auto-run tests after modification
When Claude modifies a source file, this hook finds the corresponding test file and runs it. If tests fail, Claude gets the feedback and fixes.
Event: PostToolUse | Matcher: Write
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'FILE=$(echo $CLAUDE_FILE_PATH); TEST_FILE=$(echo \"$FILE\" | sed \"s/\\.ts/.test.ts/\" | sed \"s/src\\//src\\/__tests__\\//\"); if [ -f \"$TEST_FILE\" ]; then npx jest \"$TEST_FILE\" --no-coverage 2>&1 | tail -20; fi'"
}
]
}
]
}
}
The tail -20 matters: you don’t want to flood Claude with 500 lines of Jest output. Just the failure summary is enough for it to understand and fix.
Hook 5: Slack notification when the agent finishes
Claude Code can run for a long time in the background on complex tasks. This hook sends a Slack notification when it’s done, with a summary of what happened.
Event: Stop
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 -c \"\nimport sys, json, urllib.request\ndata = json.load(sys.stdin)\nstop_reason = data.get('stop_reason', 'unknown')\npayload = json.dumps({'text': f'Claude Code finished. Reason: {stop_reason}'}).encode()\nreq = urllib.request.Request('YOUR_SLACK_WEBHOOK_URL', data=payload, headers={'Content-Type': 'application/json'})\nurllib.request.urlopen(req)\n\""
}
]
}
]
}
}
Replace YOUR_SLACK_WEBHOOK_URL with your actual webhook. You can enrich the message with the number of modified files, the last commit, or any other useful context.
Project scope vs global
Two locations, two use cases:
.claude/settings.json (in the repo) : project-specific hooks. Committed to Git, shared with the team. The project linter, test conventions, architecture constraints.
~/.claude/settings.json (home directory) : personal hooks, applied to all your projects. Security guardrails (dangerous command blocking, secret detection), Slack notifications.
In our projects, the rule is simple: security hooks are global, quality hooks live in the repo. A new developer who clones the project automatically inherits the team’s conventions. And their personal security hooks stay active regardless of the project.
Limits and best practices
Hooks are synchronous and blocking. A slow hook slows down every Claude Code action. Keep them fast: a linter on one file, not a full test suite. Save heavy checks for PostToolUse or Stop. This is one of Claude Code’s advantages over competitors. Our 2026 CLI agents comparison details the customization differences between tools.
Don’t replace your CI/CD. Hooks are a local first line of defense. They catch obvious mistakes before they leave your machine. Your CI pipeline remains essential for integration tests, deep security analysis, and deployment.
Test your hooks manually. Before activating them, run the script by hand with simulated JSON input. A hook that crashes silently is worse than no hook at all.
Start small. Two hooks are enough to get started: dangerous command blocking (global) and auto-linting (project). Add more as real needs arise, not imagined ones.
Sources
Deploying Claude Code in your team? Train your developers on AI agents →