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.

March 30, 202612 min read6 / 8

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

HookWhen It FiresCan Block?
PreToolUseBefore Claude takes an actionYes — return exit code 2 to block
PostToolUseAfter Claude takes an actionNo — informational only
StopWhen 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:

JSON
{ "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

JSON
{ "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

JSON
{ "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

JSON
{ "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

JSON
{ "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:

  1. ESLint runs on that file
  2. Auto-fixable issues are fixed immediately
  3. Remaining issues are printed to the terminal
  4. 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

JSON
{ "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

JSON
{ "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

JSON
{ "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

JSON
{ "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:

JSON
{ "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:

JSON
{ "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:

JSON
{ "hooks": { "PostToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "npx tsx .claude/hooks/post-write.ts" } ] } ] } }
TypeScript
// .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.

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.