Skip to content
Dev ResourcesTested on Claude Code 2.1.310, October 26, 2026

Claude Code Hooks That Format, Lint and Test After Every Edit

Two small hook scripts that make Claude Code check its own work: format, lint and run the related tests after every edit, then refuse to finish while a changed file fails lint. Tested in three live runs on a real Next.js repo, with the timings and the two things that surprised me.

Claude Code hooks · check every edit
The agent writes. A script checks. The agent fixes. You read the result.
Claude CodeHooksAI AgentsCoding AgentsPrettierESLintVitestDeveloper WorkflowTutorial
Cost
$0 (hooks are built in)
Time
~20 min
Steps
6
Stack
Claude Code · bash · jq · Prettier · ESLint · Vitest
What you'll get
  • ✓A hook that formats, lints and runs the related tests after every file Claude edits
  • ✓Failures sent straight back to Claude, which fixes them before it moves on
  • ✓A Stop hook that catches files written through the shell, which the edit hook never sees
  • ✓Loop protection, so a lint error Claude cannot fix never traps the session
  • ✓A headless test run that proves the hooks fire before you trust them
Share

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.

Step 1 of 6
2 min

Where hooks live

At a glance
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.

.claude/settings.json
{
  "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.

What the exit code means
Exit 0 is success. Exit 2 is a block: Claude Code sends whatever the script printed to stderr back to Claude. Any other code is an error you see and Claude does not, and the work carries on. So exit 1 is not a block. Use 2.
Step 2 of 6
5 min

The edit hook: format, lint, related tests

At a glance
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:

.claude/hooks/format-and-test.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 0

It takes the edited file's path from tool_input.file_path, skips anything that is not code, and then runs three checks in order:

  1. Prettier rewrites the file. No output, because formatting is never Claude's problem to fix.
  2. ESLint on that one file. If it fails, the errors go back to Claude.
  3. 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.

Swap in your own tools
The structure is the point, not the tools. Use 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.
Step 3 of 6
2 min

Tell Claude when it passed

At a glance
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:

.claude/hooks/format-and-test.sh (end)
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 0

additionalContext 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 formatter changes the file under Claude
In the second run Prettier reformatted a file right after Claude wrote it. Claude Code noticed and told Claude the file had changed, and Claude read it again before its next Edit. Nothing broke, but it costs one extra read. If that matters, run the formatter in the Stop hook instead.
Step 4 of 6
5 min

A Stop hook for what the edit hook misses

At a glance
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:

.claude/hooks/lint-before-stop.sh
#!/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 0

In 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.

Step 5 of 6
2 min

Keep the Stop hook from looping

At a glance
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.

The guard skips the re-check
With this guard, the second stop is not linted again. If Claude's fix did not work, it finishes anyway, and the error shows up the next time it edits that file. If you want a strict re-check, drop the guard and rely on the cap of eight, but expect the occasional long loop on an error Claude cannot fix.
Step 6 of 6
5 min

Test it headless before you trust it

At a glance
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:

Terminal
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:

RunWhat I asked forWhat happenedTimeUsage
1Write a file with a buggy slugify and a word this repo's lint rule bans, then a test for itThe edit hook blocked twice: once on ESLint, once on the failing test. Claude fixed both.24 s, 4 turns$0.30
2Write a one-line, unformatted file, a test, then Edit the same filePrettier 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
3Create a file through the shell with printf, with the banned word in it, then stopThe 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.

Long test suites belong in the background
A hook with "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 /hooks in 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 timeout per hook and keep each check scoped to one file.
  • You need to see what happened. Start Claude Code with --debug-file and search the log for Hooks: 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.

Share

Get the weekly recap

New dev-resource guides, top launches, and what's worth a look. One email a week.