Automate Code Quality in Claude Code with Hooks
8 min read · Reviewed 2026-06-13
Every time an AI coding agent writes a file, you want that file to be formatted, lint-clean, and type-correct before the session moves on. Every time it finishes a task, you want the tests to pass. The problem is that prompt instructions — even in a CLAUDE.md — are probabilistic: the model may follow them, drift on a long turn, or decide an exception applies.
Hooks eliminate the uncertainty. A PostToolUse hook runs on every file write, in its own process, before the session continues. A Stop hook runs every time the agent finishes a turn, regardless of whether the model remembered to check. This guide shows exactly what each quality hook looks like, how to wire it up, and how to combine them into a quality gate the model cannot skip.
Why automate code quality with hooks instead of asking the agent?
When you write a quality rule in CLAUDE.md, you are making a request the model will usually honour. A hook makes it a rule the runtime enforces. The difference matters in practice: a hook runs on every matching event, consumes zero context tokens unless it surfaces a problem, and cannot be deprioritized by the model in a long session.
Prompt instructions express intent. Hooks encode invariants. For anything that must be true at every turn — formatted files, zero lint errors, passing types — an invariant is what you want. The model is free to focus on the task; the quality pipeline runs regardless.
How do you format every file the moment it is written?
Register a PostToolUse hook with the matcher Write|Edit. It fires every time the agent writes or edits a file. Filter by extension to skip non-formattable files, then call Prettier with --write inside a silent try/catch — if Prettier is absent, the hook exits quietly and nothing breaks.
// .claude/hooks/post-write-autoformat.mjs
import { readFileSync } from 'fs'
import { execSync } from 'child_process'
import { fileURLToPath } from 'url'
function defaultExec(cmd) {
execSync(cmd, { stdio: 'ignore', timeout: 10_000 })
}
export function run(input, { exec = defaultExec } = {}) {
const filePath = input.tool_input?.file_path ?? ''
if (!filePath) return null
try {
exec(`npx --no-install prettier --write "${filePath}"`)
return { formatted: filePath }
} catch {
return null // formatter absent or non-fatal error — stay silent
}
}
/* v8 ignore next 4 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const input = JSON.parse(readFileSync(0, 'utf8'))
run(input)
}Wire it in settings.json under PostToolUse with the Write|Edit matcher. PostToolUse hooks are non-blocking by convention: the session continues whether or not Prettier is installed.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/post-write-autoformat.mjs" }
]
}
]
}
}How do you lint and auto-fix every file automatically?
The ESLint hook follows the same shape — PostToolUse, Write|Edit matcher, extension filter — but it surfaces unfixable errors to Claude instead of staying fully silent. The lint report goes to stderr so the model reads it and can correct the remaining issues on the next edit.
// .claude/hooks/post-write-eslint.mjs
import { readFileSync } from 'fs'
import { execSync } from 'child_process'
import { fileURLToPath } from 'url'
function defaultExec(cmd) {
return execSync(cmd, { stdio: 'pipe', timeout: 15_000 })
}
export function run(input, { exec = defaultExec } = {}) {
const filePath = input.tool_input?.file_path ?? ''
if (!filePath || !/\.[cm]?[jt]sx?$/.test(filePath)) return null
try {
exec(`npx --no-install eslint --max-warnings=0 "${filePath}"`)
return null
} catch (err) {
const output = err.stdout?.toString() ?? ''
return output ? { message: `ESLint: ${output.trim()}\n` } : null
}
}
/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const input = JSON.parse(readFileSync(0, 'utf8'))
const result = run(input)
if (result?.message) process.stderr.write(result.message)
}Stack both hooks under the same Write|Edit matcher group in settings.json — the formatter runs first so ESLint sees the already-formatted file.
How do you catch type errors automatically?
Running tsc --noEmit after every single file write is expensive — the TypeScript compiler does a full project check each time, which is redundant when several files change in one batch. A PostToolBatch hook is the right event: it fires after the agent completes a group of edits, inspects which files changed, skips the run entirely if none are TypeScript, and surfaces compiler errors as context the model can act on.
// .claude/hooks/post-tool-batch-typecheck.mjs
import { readFileSync } from 'fs'
import { execSync } from 'child_process'
import { fileURLToPath } from 'url'
function defaultExec(cmd) {
return execSync(cmd, { stdio: 'pipe', timeout: 30_000 })
}
export function run(input, { exec = defaultExec } = {}) {
const calls = input.tool_calls ?? []
const hasTs = calls.some(
(c) => ['Write', 'Edit'].includes(c.tool_name) && /\.tsx?$/.test(c.tool_input?.file_path ?? ''),
)
if (!hasTs) return null
try {
exec('npx --no-install tsc --noEmit --pretty false 2>&1')
return null
} catch (e) {
const out = (e.stdout ?? e.stderr ?? '').toString().slice(0, 2000)
return {
hookSpecificOutput: {
hookEventName: 'PostToolBatch',
additionalContext: `TypeScript errors after batch edit:\n${out}`,
},
}
}
}
/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const input = JSON.parse(readFileSync(0, 'utf8'))
const result = run(input)
if (result) process.stdout.write(JSON.stringify(result))
}Register this under PostToolBatch, not PostToolUse. The batch event gives you a single compiler run after a group of changes rather than one noisy invocation per file.
How do you guarantee tests pass before the agent hands back control?
The Stop event fires every time the agent finishes a turn and returns control to you. A Stop hook at that moment turns a completed turn into a verified one. If the suite fails, exit code 2 feeds the stderr output back to Claude as context and the agent continues working.
// .claude/hooks/stop-run-tests.mjs
import { readFileSync } from 'fs'
import { execSync } from 'child_process'
import { fileURLToPath } from 'url'
const TEST_CMD = process.env.HOOKSTACK_TEST_CMD ?? 'npm test'
export function run(_input, {
exec = (cmd) => execSync(cmd, { stdio: 'pipe', timeout: 120_000 }),
testCmd = TEST_CMD,
} = {}) {
try {
exec(testCmd)
return null // tests passed — stay silent
} catch (err) {
const out = (err.stdout ?? err.stderr ?? '').toString().slice(0, 3000)
return { failed: true, output: out }
}
}
/* v8 ignore next 7 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const input = JSON.parse(readFileSync(0, 'utf8'))
const result = run(input)
if (result?.failed) {
process.stderr.write(`Tests failed:\n${result.output}\n`)
process.exit(2) // exit 2 → stderr is fed back to Claude as context
}
}Set HOOKSTACK_TEST_CMD to your test command (pnpm test, pytest, cargo test) and the hook reads it at runtime. The 120-second timeout covers most suites; raise it for slow integration runs.
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/stop-run-tests.mjs" }
]
}
]
}
}Pre-write vs post-write: when should each quality check run?
Quality hooks nearly always go on PostToolUse or Stop — they react to completed changes rather than speculative ones. The practical split by cost and scope:
PostToolUse(matcherWrite|Edit) — format and lint a single file. Runs immediately after every write; stays fast by filtering on extension.PostToolBatch— typecheck. One compiler run covers all files in the batch, far cheaper than runningtscper file.Stop— test suite, coverage gate. Runs once at the end of a turn, not after each individual file.
PreToolUse quality checks make sense in one specific case: blocking a write to a read-only or auto-generated file. For everything else, PostToolUse is the right event. The PreToolUse vs PostToolUse guide (linked below) covers the full event comparison and which events can block.
How do you assemble and install a complete quality gate?
With the four hooks on disk, the settings.json wires them together — format and lint run on every write, typecheck runs once per batch, and tests gate every finished turn:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/post-write-autoformat.mjs" },
{ "type": "command", "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/post-write-eslint.mjs" }
]
}
],
"PostToolBatch": [
{
"hooks": [
{ "type": "command", "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-batch-typecheck.mjs" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/stop-run-tests.mjs" }
]
}
]
}
}All four hooks are in the HookStack catalogue. Install the full set in one command:
npx hookstack-cli@latest installThe CLI writes each script to .claude/hooks/ and patches your settings.json with the correct event and matcher. From the next session on, every file the agent writes is formatted and linted immediately, types are verified after each batch of edits, and the test suite must pass before the session closes. Quality becomes an invariant, not a reminder.
Frequently asked questions
- Can a PostToolUse hook break my Claude Code session if a tool is missing?
- Not if it is written correctly. HookStack quality hooks wrap external tools in a silent try/catch so a missing formatter or linter exits quietly and the session continues. The hook returns null and control passes through.
- How do I run tests automatically in Claude Code?
- Register a Stop hook that runs your test command. The Stop event fires every time Claude finishes a turn. Set HOOKSTACK_TEST_CMD to your command (pnpm test, pytest, cargo test) and use exit code 2 to feed test failures back to Claude as context.
- Should I run tsc after every file write?
- Better to use a PostToolBatch hook: it fires after a group of edits, checks the full project once, and skips entirely if no TypeScript files were touched. Running tsc on every single write is slow — one pass per batch is enough.
- Can I auto-format files in Claude Code without writing a hook myself?
- Yes. Install the post-write-autoformat hook from the HookStack catalogue with npx hookstack-cli@latest install. The CLI writes the script and registers it in settings.json so Prettier runs on every file the agent writes without any manual setup.
Related hooks
- Automatic formatting after writeEvery file lands already Prettier-clean
- ESLint lint after writeLint errors fixed in the same loop, not in CI
- TypeScript type checkingType errors caught the moment a file is saved
- Typecheck after parallel file editsOne tsc pass after a batch, not one per file
- Run tests at end of responseWon't hand back until the test suite is green
- Automatic quality check (Stop)Types and lint must pass before Claude hands back control
- Missing test detection (Stop)No source file ships without a test
- Auto-run tests when source files changeTests rerun the moment a source file changes
Sources
Read next
- PreToolUse vs PostToolUse: Which Claude Code Hook to UsePreToolUse runs before a tool and can block it; PostToolUse runs after and reacts. See two complete hooks, the differing stdin payloads, and a one-line rule.
- 10 Claude Code Hooks Worth Installing (With Examples)A curated list of useful Claude Code hooks examples: block secrets and destructive commands, lint on save, run tests on stop, and inject conventions.
- Write Your First Claude Code Hook in 5 MinutesA step-by-step tutorial: create the hooks folder, write a Bash command logger, register it in settings.json, test it, and watch it run. No deps beyond Node.