Hooks: Automated Quality Gates
Claude Code hooks are shell commands that run automatically before or after the AI takes action. PreToolUse can block bad behavior. PostToolUse can enforce quality gates. Here's how to use both.
Claude Code can execute hooks — shell commands that fire automatically at specific points in the agent's workflow. This is how you build quality enforcement that doesn't depend on the AI following instructions.
Instructions say "please do this." Hooks enforce it.
The Three Hook Types
| Hook | When It Fires | Can Block? |
|---|---|---|
PreToolUse | Before Claude takes an action | Yes — return exit code 2 to block |
PostToolUse | After Claude takes an action | No — informational only |
Stop | When the agent stops (task complete or error) | No |
Where to Configure Hooks
Hooks live in your project's .claude/settings.json or in your global Claude Code settings:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "your-pre-command-here"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "your-post-command-here"
}
]
}
]
}
}The matcher field is a regex that matches tool names: Write, Edit, Bash, Read, etc.
PreToolUse: Blocking Bad Actions
PreToolUse hooks run before Claude acts. If the hook exits with code 2, Claude Code blocks the action and explains why.
Blocking Dangerous Git Commands
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo \"$CLAUDE_TOOL_INPUT\" | grep -qE 'git (push|force|reset --hard|clean -f)' && exit 2 || exit 0"
}
]
}
]
}
}This blocks Claude from running git push, force operations, hard resets, or git clean. The AI can still use git for reading (git log, git diff, git status) — only dangerous write operations are blocked.
Why this matters: an agent working across many files might decide to "clean up" by running git clean -f. Without a hook, that's gone. With this hook, it's blocked and you get to decide.
Blocking --no-verify
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'no-verify' && exit 2 || exit 0"
}
]
}
]
}
}git commit --no-verify bypasses your pre-commit hooks. Pre-commit hooks run your linter, type checker, and tests before every commit. Bypassing them defeats the entire automated quality system. This hook ensures the AI can never skip them.
Blocking Production Access
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo \"$CLAUDE_TOOL_INPUT\" | grep -qE '(production|prod)' && exit 2 || exit 0"
}
]
}
]
}
}Block any bash command containing "production" or "prod." This is a safety net for agents that might try to run migrations or deployments against production environments.
PostToolUse: Automated Quality Enforcement
PostToolUse hooks run after an action and can't block it — but they can report results that Claude Code sees and acts on.
Auto-Linting After Every File Change
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r '.path // empty'); [ -n \"$file\" ] && npx eslint --fix \"$file\" 2>&1 | head -20 || true"
}
]
}
]
}
}Every time Claude Code writes or edits a file:
- ESLint runs on that file
- Auto-fixable issues are fixed immediately
- Remaining issues are printed to the terminal
- Claude Code sees those issues and fixes them on the next pass
This is the automated feedback loop. The AI doesn't need to remember to check linting — the hook just runs it. Errors that would appear in code review are caught in seconds.
Running TypeScript After Changes
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npx tsc --noEmit 2>&1 | head -30"
}
]
}
]
}
}After every file change, TypeScript checks the project. Any type errors appear immediately. Claude Code sees them and can fix them before moving on.
Caution: full TypeScript compilation after every file write is slow on large projects. You might want to run this as a Stop hook instead (see below).
Counting Changed Files
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "echo \"Files changed this session: $(git diff --name-only | wc -l | tr -d ' ')\""
}
]
}
]
}
}A visibility hook — just prints a count so you can notice when the agent has touched 20+ files unexpectedly.
Stop Hooks: End-of-Session Checks
Stop hooks fire when the agent finishes (or errors out). Good for checks that should happen once at the end, not after every file.
Full Test Run at Completion
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "npm test 2>&1 | tail -20"
}
]
}
]
}
}When the agent considers its task complete, run the test suite. If tests fail, the output appears in the terminal and Claude Code can see the failures to address them.
Git Status Summary
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "echo '=== Session Summary ===' && git diff --stat && echo '====================='"
}
]
}
]
}
}At the end of every session, print what changed. Makes reviewing the agent's work much easier — you see a summary instead of hunting through files.
The Environment Variable
Inside hook commands, $CLAUDE_TOOL_INPUT contains the tool's input as JSON. You can inspect it to make decisions:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r '.path'); echo \"About to write: $file\""
}
]
}
]
}
}Useful for logging, for conditional checks, or for blocking writes to specific files or directories.
A Practical Starter Configuration
This is a reasonable baseline for most projects:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'no-verify' && exit 2 || exit 0"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r '.path // empty'); [ -n \"$file\" ] && [ -f \"$file\" ] && npx eslint --fix \"$file\" 2>&1 | head -20 || true"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "git diff --stat 2>/dev/null"
}
]
}
]
}
}Blocks --no-verify. Auto-lints on every file change. Summarizes diffs at the end.
The TypeScript Hook Library
Writing hook commands as raw bash strings is clunky. For more complex hooks, you can write them as TypeScript scripts and invoke them from the configuration:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npx tsx .claude/hooks/post-write.ts"
}
]
}
]
}
}// .claude/hooks/post-write.ts
import { execSync } from 'child_process';
const toolInput = JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}');
const filePath = toolInput.path;
if (filePath && filePath.endsWith('.ts') || filePath?.endsWith('.tsx')) {
try {
execSync(`npx eslint --fix "${filePath}"`, { stdio: 'pipe' });
const result = execSync(`npx eslint "${filePath}"`, { stdio: 'pipe' });
console.log(result.toString());
} catch (err: any) {
console.error(err.stdout?.toString());
process.exit(1);
}
}More verbose, but easier to maintain and extend. The hook logic is in a real file with proper error handling.
The Mental Model
Rules and CLAUDE.md say: "please do this."
Hooks enforce it: the linter runs whether the AI meant to check it or not. The dangerous git commands don't execute whether the AI thought they were safe or not.
The combination — clear CLAUDE.md + strict ESLint + targeted hooks — creates a system where the AI's output is checked at every step, not just at the end. Mistakes are caught in seconds. Quality isn't a review process; it's an automated constraint.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.