Coding agents are good at writing code and bad at noticing what they broke. The longer the task, the worse it gets: a recurring complaint on OpenAI's developer forum is Codex drifting from the original goal on long runs, and people checking in every hour or two to pull it back. Watching is the expensive fix. The cheap one is to make the agent check its own work after every step.
Claude Code does this with hooks: commands it runs at fixed points, like after a file edit or before it ends its turn. A hook can send its output back to Claude, and Claude acts on it. This guide sets up two. One formats, lints and tests every file Claude edits. The other refuses to let Claude finish while a changed file fails lint.
I wrote both for the Nick Launches codebase, a Next.js app with Prettier, ESLint and 73 Vitest test files, and ran three live sessions with them on Claude Code 2.1.310. The timings and results below are from those runs.
Where hooks live
- Project file
- .claude/settings.json
- Personal file
- .claude/settings.local.json
- Needs
- jq
Hooks go in a settings file. .claude/settings.json is committed, so your whole team gets them. .claude/settings.local.json is just for you. Each hook has three levels: the event, a matcher that picks which tools it applies to, and the command to run.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-and-test.sh",
"timeout": 120
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/lint-before-stop.sh",
"timeout": 120
}
]
}
]
}
}Claude Code passes each hook a JSON object on stdin: the session, the event, and for tool events the tool's name and input. The scripts below read it with jq, so install that first: brew install jq on a Mac. CLAUDE_PROJECT_DIR is the folder the session started in, so the scripts work from any subfolder.
The edit hook: format, lint, related tests
- Event
- PostToolUse
- Matcher
- Edit|Write
- Time per edit
- 2.4 to 4.1 s
Save this as .claude/hooks/format-and-test.sh and run chmod +x .claude/hooks/*.sh:
#!/bin/bash
# PostToolUse on Edit|Write: format the file, lint it,
# then run only the tests that import it.
# Exit 2 sends stderr back to Claude. The edit itself is already on disk.
INPUT=$(cat)
FILE=$(jq -r '.tool_input.file_path // empty' <<<"$INPUT")
[[ -z "$FILE" || ! -f "$FILE" ]] && exit 0
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx|*.mjs) ;;
*) exit 0 ;;
esac
cd "$CLAUDE_PROJECT_DIR" || exit 0
npx prettier --write --log-level warn "$FILE" >/dev/null 2>&1
if ! LINT=$(npx eslint --no-warn-ignored "$FILE" 2>&1); then
echo "ESLint failed on $FILE. Fix these before moving on:" >&2
echo "$LINT" | tail -30 >&2
exit 2
fi
if ! TESTS=$(npx vitest related "$FILE" --run --passWithNoTests 2>&1); then
echo "Tests that cover $FILE are failing:" >&2
echo "$TESTS" | grep -v '^\s*$' | tail -30 >&2
exit 2
fi
exit 0It takes the edited file's path from tool_input.file_path, skips anything that is not code, and then runs three checks in order:
- Prettier rewrites the file. No output, because formatting is never Claude's problem to fix.
- ESLint on that one file. If it fails, the errors go back to Claude.
- Vitest related runs only the test files that import the edited file, not the whole suite. If one fails, the failure goes back to Claude.
On this repo the whole hook took 2.4 to 4.1 seconds per edit, most of it npx starting three tools. In the first test run it caught both problems I planted. Claude wrote a file with a word in a string that a custom lint rule in this repo bans from site copy, and the hook blocked with the rule's message. Claude then wrote a test for a slugify that only replaced the first space, and the hook blocked with the failing assertion. Claude fixed both without being asked, using replaceAll and the word the rule asks for.
ruff format, ruff check and pytest on a Python project, or gofmt and go test ./... on Go. Keep each check scoped to the edited file or its tests, or the hook gets slow.Tell Claude when it passed
- Field
- additionalContext
- Output
- One JSON line on stdout
- Why
- Silence is not a pass
The first version of the hook printed nothing on success. At the end of that run, Claude reported: "The hooks printed nothing after the fix, which probably means they passed, but I didn't run the test to confirm." To Claude, silence means it doesn't know. So say it out loud. Replace the last exit 0 with:
SUMMARY=$(echo "$TESTS" | grep -E '^\s*Tests ' | sed 's/^ *//' | head -1)
MSG="Hook: formatted with Prettier, ESLint clean,"
MSG="$MSG related tests: ${SUMMARY:-none found}."
jq -nc --arg msg "$MSG" \
'{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $msg}}'
exit 0additionalContext reaches Claude as a note next to the tool result. In the second run, Claude quoted it back after each step: "formatted with Prettier, ESLint clean, related tests: Tests 1 passed (1)".
A Stop hook for what the edit hook misses
- Event
- Stop
- Checks
- Every changed .ts file
- Time
- 1.5 to 1.9 s
The edit hook only fires on Claude's Edit and Write tools. A file Claude creates through the shell, with printf, sed or a heredoc, never triggers it. In the third run I asked Claude to do exactly that, and the edit hook stayed silent.
A Stop hook runs when Claude is about to end its turn. This one lints every TypeScript file that differs from the last commit, plus new untracked ones, however they got there:
#!/bin/bash
# Stop: refuse to end the turn while a changed TypeScript file fails lint.
INPUT=$(cat)
# Already continuing because of a Stop hook: let Claude stop this time.
[[ "$(jq -r '.stop_hook_active' <<<"$INPUT")" == "true" ]] && exit 0
cd "$CLAUDE_PROJECT_DIR" || exit 0
FILES=$( { git diff --name-only --diff-filter=ACMR HEAD -- '*.ts' '*.tsx';
git ls-files --others --exclude-standard -- '*.ts' '*.tsx'; } | sort -u )
[[ -z "$FILES" ]] && exit 0
if ! OUT=$(npx eslint --no-warn-ignored $FILES 2>&1); then
echo "Lint still fails on files you changed. Fix it before you finish:" >&2
echo "$OUT" | tail -30 >&2
exit 2
fi
exit 0In the third run it found the shell-written file, blocked the finish with ESLint's message, and Claude fixed the file and tried to stop again. Exit 2 on a Stop hook means "keep going", and the stderr text becomes Claude's next instruction.
Keep the Stop hook from looping
- Input field
- stop_hook_active
- Built-in cap
- 8 blocks in a row
- Second stop
- 17 ms
A Stop hook that blocks on something Claude cannot fix would block forever. Claude Code passes stop_hook_active: true when Claude is already continuing because of a Stop hook. The script's first check lets Claude stop in that case, which is why the second stop in run three took 17 milliseconds.
Claude Code also has its own cap: after Stop hooks continue the turn eight times in a row, it overrides the next block and ends the turn. The count resets every time Claude calls a tool.
Test it headless before you trust it
- Command
- claude -p --settings
- Log
- --debug-file
- Per run
- 20 to 32 s
Before committing the hooks, run a throwaway session that is designed to trip them. Point --settings at the file, write a debug log, and give Claude a task with a planted bug:
claude -p --settings .claude/settings.json --permission-mode acceptEdits \
--debug-file /tmp/hooks.log --output-format json \
"Create src/lib/hooktest/slug.ts exporting
slugify = (s: string) => s.toLowerCase().replace(' ', '-'),
then a vitest test expecting slugify('Hello Big World')
to be 'hello-big-world'. Fix only what a hook reports.
Finish with one line saying what the hooks told you."
grep "Hooks: " /tmp/hooks.log
# PostToolUse:Write [.../format-and-test.sh] finished with status 2 (2450ms)
# PostToolUse:Edit [.../format-and-test.sh] finished with status 0 (4072ms)
# Stop [.../lint-before-stop.sh] finished with status 0 (1527ms)Status 2 means the hook blocked and Claude got the message. Then delete the test folder. These are the three runs I made:
| Run | What I asked for | What happened | Time | Usage |
|---|---|---|---|---|
| 1 | Write a file with a buggy slugify and a word this repo's lint rule bans, then a test for it | The edit hook blocked twice: once on ESLint, once on the failing test. Claude fixed both. | 24 s, 4 turns | $0.30 |
| 2 | Write a one-line, unformatted file, a test, then Edit the same file | Prettier rewrote the file after the write. Claude Code told Claude the file had changed, so it read it again before the Edit. Everything passed. | 32 s, 5 turns | $0.29 |
| 3 | Create a file through the shell with printf, with the banned word in it, then stop | The edit hook never fired. The Stop hook blocked the finish on ESLint, Claude fixed the word, and the second stop went through. | 20 s, 5 turns | $0.27 |
Usage is the cost Claude Code reported for each headless run. On a Pro or Max plan it counts against your plan's limits rather than a bill.
"async": true runs without blocking Claude, and its result arrives on the next turn. That suits a full test suite. An async hook cannot block anything, though, so keep the fast checks synchronous.What it actually costs
- The hooks: $0. They are part of Claude Code, and the tools are the ones your project has.
- Time: 2.4 to 4.1 seconds after each edit and about 2 seconds at the end of each turn on this repo.
- Tokens: a blocked hook adds its message and one fix to the session. In my runs, sessions with two blocks and with none cost about the same, $0.27 to $0.30.
Troubleshooting
- The hook never runs. Check the matcher spells the tool names exactly (
Edit|Write), the script is executable, and/hooksin Claude Code lists it. - Claude ignores a failure. The script probably exits 1. Only exit 2 sends stderr back to Claude.
- jq: command not found. The script then reads an empty path and exits 0 every time. Install jq.
- The hook times out. The default timeout for a command hook is 600 seconds; set a lower
timeoutper hook and keep each check scoped to one file. - You need to see what happened. Start Claude Code with
--debug-fileand search the log forHooks:lines. Each one shows the event, the command, the exit status and the time it took.
You're set
Claude now formats, lints and tests every file it touches, fixes what fails before it moves on, and cannot end a turn with a lint error in the diff. Commit the two scripts and the settings file, and everyone who opens the repo in Claude Code gets the same checks.
Built something with Claude Code? Launch it on the weekly board: launching is free, every launch is reviewed by a person, and it gets a permanent listing page. Submit it here.
Disclaimer: Nick Launches is not affiliated with Anthropic. Tested on October 26, 2026 with Claude Code 2.1.310 on macOS, Prettier 3.8, ESLint with eslint-config-next, and Vitest 5.0.1. See the hooks reference for every event and field.