Claude Code hooks: five examples that work
A hook is a small program that Claude Code runs at a fixed moment: before a tool, after an edit, when a session starts, when Claude finishes. Unlike an instruction in CLAUDE.md, which Claude may or may not follow, a hook always runs. Here are five you can copy. Each has automated tests, and we also ran each one in a real Claude Code 2.1.278 session on Windows in September 2026.
How a hook is wired: pick an event (such as PostToolUse), add a matcher to say which tool it applies to, and give it a handler, here a Node script. The script reads the event as JSON on standard input. Exit code 0 means carry on. Exit code 2 means block, or "pay attention", depending on the event.
Where the settings go
.claude/settings.jsonin the project: shared with your team through git..claude/settings.local.json: yours only, for this project.~/.claude/settings.json: your hooks in every project.
All the examples below start Node directly ("command": "node" with an args list) and use ${CLAUDE_PROJECT_DIR} to find the script, so they work from any folder and on Windows. Save each script in .claude/hooks/. The full settings file for all of them is at the end.
1. Block git push (and other risky commands)
Event: PreToolUse. It runs before the command and can stop it. We wrote this one up separately, because permission rules alone miss git -C . push: how to block git push, rm -rf and .env reads.
2. Format every file Claude edits
Event: PostToolUse, matcher Edit|Write. After Claude writes a file, this runs the project's own Prettier on it, so code always comes out in your style. It needs Prettier installed in the project (npm install --save-dev prettier) and quietly does nothing if it isn't.
// .claude/hooks/format-after-edit.mjs (PostToolUse, matcher "Edit|Write")
// Runs Prettier on a file right after Claude edits it. Needs Prettier installed in the project.
import { existsSync } from 'node:fs';
import { join, extname } from 'node:path';
import { spawnSync } from 'node:child_process';
let raw = '';
for await (const chunk of process.stdin) raw += chunk;
const input = JSON.parse(raw);
const file = input.tool_input?.file_path;
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? input.cwd;
const prettier = join(projectDir, 'node_modules', 'prettier', 'bin', 'prettier.cjs');
const formattable = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.json', '.css', '.scss', '.html', '.md', '.yml', '.yaml'];
if (!file || !formattable.includes(extname(file).toLowerCase()) || !existsSync(prettier)) process.exit(0);
const result = spawnSync(process.execPath, [prettier, '--write', file], { encoding: 'utf8' });
if (result.status !== 0) {
// After a tool has run, exit code 2 shows this message to Claude so it can fix the problem.
console.error(`Prettier could not format ${file}:\n${(result.stderr || result.stdout).trim()}`);
process.exit(2);
}
What we saw: we asked Claude to write a file containing const a={b:1}. The file on disk afterwards read const a = { b: 1 };. If Prettier can't parse a file, the script exits with code 2. After a tool has already run, exit code 2 shows the error message to Claude, so it can fix its own syntax error.
Good to know: this hook fires only when Claude uses its Edit or Write tools. Claude Code's documentation says it does not fire when a shell command or another program rewrites the file. On Windows the file path arrives with backslashes, and this script handles that.
3. Give Claude the git situation at session start
Event: SessionStart. Whatever the script prints is added to Claude's context before your first prompt. This one reports the branch, how many files are uncommitted, and the last three commits, so Claude doesn't spend a tool call finding out.
// .claude/hooks/session-context.mjs (SessionStart)
// Whatever this prints is added to Claude's context when the session starts.
import { spawnSync } from 'node:child_process';
const git = (...args) => {
const r = spawnSync('git', args, { encoding: 'utf8' });
return r.status === 0 ? r.stdout.trim() : '';
};
const branch = git('rev-parse', '--abbrev-ref', 'HEAD');
if (!branch) process.exit(0); // not a git repository: say nothing
const changed = git('status', '--porcelain').split('\n').filter(Boolean).length;
console.log(`Git context: on branch ${branch} with ${changed} uncommitted file(s).`);
console.log(`Recent commits:\n${git('log', '--oneline', '-3')}`);
What we saw: we asked Claude which branch the repository was on and the last commit message, and told it not to run any commands. It answered correctly (trunk and the right message) from the hook's output alone. Use the matcher startup|resume|clear to run it on new, resumed and cleared sessions. This hook runs on every session, so keep it fast. For static facts that never change, write them in CLAUDE.md instead.
4. Run the tests before Claude stops
Event: Stop, which fires when Claude finishes responding. If the tests fail, the script exits with code 2 and prints the failure. Claude is told why and keeps working instead of stopping.
// .claude/hooks/run-tests-on-stop.mjs (Stop)
// When Claude finishes, run the project's tests. If they fail, exit 2: Claude is told why
// and keeps working instead of stopping. Claude Code ends the turn after 8 blocks in a row.
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
let raw = '';
for await (const chunk of process.stdin) raw += chunk;
const dir = JSON.parse(raw).cwd ?? process.cwd();
// Nothing changed since the last commit? Then there is nothing to check.
const status = spawnSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8' });
if (status.status !== 0 || !status.stdout.trim()) process.exit(0);
// No "test" script in package.json? Then there is nothing to run.
const pkg = join(dir, 'package.json');
if (!existsSync(pkg) || !JSON.parse(readFileSync(pkg, 'utf8')).scripts?.test) process.exit(0);
const run = spawnSync('npm', ['test', '--silent'], { cwd: dir, encoding: 'utf8', timeout: 120_000, shell: process.platform === 'win32' });
if (run.status !== 0) {
const tail = `${run.stdout}${run.stderr}`.trim().split('\n').slice(-30).join('\n');
console.error(`The tests are failing. Fix them before you finish:\n${tail}`);
process.exit(2);
}
What we saw: with a deliberately failing test, we asked Claude to reply "OK". It did. The hook then ran the tests, they failed, and Claude went straight back to work: it searched for the failing test file and tried to fix it, instead of stopping.
Watch the cost. Every block sends Claude round for another turn. Claude Code's documentation says it ends the turn on its own after 8 blocks in a row, so a test that can never pass will not loop forever, but it can use real time and tokens. The script skips the tests when nothing has changed since the last commit, and when there is no test script. Run only a fast test suite here.
5. Keep a log of every command Claude runs
Event: PostToolUse, matcher Bash|PowerShell. This appends one line per command to .claude/command-log.jsonl, a record you can search later.
// .claude/hooks/log-commands.mjs (PostToolUse, matcher "Bash|PowerShell")
// Adds one line to .claude/command-log.jsonl for every shell command Claude runs.
import { appendFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
let raw = '';
for await (const chunk of process.stdin) raw += chunk;
const input = JSON.parse(raw);
const dir = join(process.env.CLAUDE_PROJECT_DIR ?? input.cwd, '.claude');
mkdirSync(dir, { recursive: true });
appendFileSync(join(dir, 'command-log.jsonl'), JSON.stringify({ time: new Date().toISOString(), tool: input.tool_name, command: input.tool_input?.command }) + '\n');
What we saw: after asking Claude to run echo hi, the log held {"time":"…","tool":"Bash","command":"echo hi"}. Commands can contain secrets, so add .claude/command-log.jsonl to your .gitignore.
All five in one settings file
This is examples 2 to 5 (example 1 is in the git push guide). It passes Claude Code's published settings schema. Save it as .claude/settings.json.
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/session-context.mjs"] }
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/format-after-edit.mjs"] }
]
},
{
"matcher": "Bash|PowerShell",
"hooks": [
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-commands.mjs"] }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests-on-stop.mjs"], "timeout": 150 }
]
}
]
}
}
When a hook doesn't seem to run
- Type
/hooksinside Claude Code to see which hooks are loaded. - Write a debug log. Start Claude Code with
claude --debug-file hooks.logand read the file. It records each hook that ran and what it returned. - Exit code 1 does nothing useful. For most events only exit code 2 blocks or reports. Exit 1 shows a non-blocking "hook error" notice and carries on.
- Timeouts. A hook that runs too long is cancelled. Give slow hooks a
timeout, in seconds, as the Stop hook above does. - A path typo fails quietly. If the script can't be found, Claude Code shows a non-blocking error and carries on as if the hook did not exist. Check the first run.
- Windows. Without Git Bash, Claude Code has no Bash tool and uses PowerShell, so match
Bash|PowerShell. In exec form,nodeworks everywhere, butnpmandnpxare.cmdshims that can't be started this way.
Hooks enforce, instructions ask
Claude Code's own documentation says CLAUDE.md is "context, not enforced configuration," and points to hooks for anything that must happen regardless of what Claude decides. That is the reason to use hooks for the rules that matter. See how CLAUDE.md loads for the other half.
Want a ready-made set? The Safe setup pack bundles guard hooks for commands and files, deny rules, and two slash commands, with 199 automated tests.