Hooks — Real-World Examples
Hooks run shell commands at specific points in Claude’s lifecycle. They are how you enforce rules automatically — without having to remind Claude every session.
How to Think About Hooks
Section titled “How to Think About Hooks”Without hooks: You tell Claude “remember to run lint after editing” — it might forget, or you might forget to tell it.
With hooks: The harness runs lint automatically after every edit. Claude sees the output. No reminders needed.
A hook is just: when <event> happens [and <condition> matches], run <command>.
Hook 1: Auto-Format After Every Edit
Section titled “Hook 1: Auto-Format After Every Edit”Problem: Claude edits a file but doesn’t always run Prettier afterward. Solution: Run Prettier automatically.
{ "hooks": [ { "event": "PostToolUse", "if": { "tool": "Edit" }, "run": "sh", "args": ["-c", "prettier --write \"$file_path\" 2>/dev/null || true"], "continueOnError": true } ]}What happens:
- Claude calls
Edit("src/services/user.ts", ...) - File is updated
- Hook fires:
prettier --write "src/services/user.ts" - File is formatted
- Claude continues
continueOnError: true means if Prettier isn’t installed, the workflow continues anyway.
Hook 2: TypeScript Check After Editing .ts Files
Section titled “Hook 2: TypeScript Check After Editing .ts Files”Problem: Claude makes a TypeScript edit that introduces a type error.
Solution: Run tsc after every TypeScript edit and show Claude the error.
{ "hooks": [ { "event": "PostToolUse", "if": { "tool": "Edit", "path": "**/*.ts" }, "run": "sh", "args": ["-c", "npx tsc --noEmit 2>&1 | head -20 || true"], "continueOnError": true } ]}What happens:
- Claude edits
src/services/payment.ts - Hook fires:
npx tsc --noEmit - If type error: output goes back to Claude — it sees and fixes the error
- If clean: nothing visible, Claude continues
head -20 limits output to avoid flooding context with hundreds of errors.
Hook 3: Block Edits to Protected Files
Section titled “Hook 3: Block Edits to Protected Files”Problem: You don’t want Claude editing migration files, .env, or generated files.
Solution: PreToolUse hook that denies the tool call (exit code 2).
{ "hooks": [ { "event": "PreToolUse", "if": { "tool": "Edit" }, "run": "bash", "args": [ "-c", "case \"$file_path\" in prisma/migrations/*|.env*|src/generated/*) echo 'BLOCKED: $file_path is protected' >&2; exit 2;; esac" ] } ]}Exit codes:
0— allow the tool call1— error, stop Claude2— deny this specific tool call (Claude sees “permission denied” and adjusts)
What happens:
- Claude tries to edit
prisma/migrations/20240101_init.sql - Hook fires, detects it’s a migration file
- Exits with code 2
- Tool call is denied
- Claude sees the blocked message and asks you how to proceed instead
Hook 4: Run Tests After Editing Test Files
Section titled “Hook 4: Run Tests After Editing Test Files”Problem: When Claude writes a test, you want to immediately know if it passes. Solution: Auto-run the specific test file after it’s edited.
{ "hooks": [ { "event": "PostToolUse", "if": { "tool": "Edit", "path": "**/*.test.ts" }, "run": "sh", "args": ["-c", "npx vitest run \"$file_path\" 2>&1 | tail -20"], "continueOnError": true } ]}What happens:
- Claude writes
tests/services/payment.test.ts - Hook fires: runs only that test file
- Output (pass/fail) goes back to Claude
- If tests fail: Claude sees the failure and can fix the implementation immediately
Hook 5: Desktop Notification When Claude Stops
Section titled “Hook 5: Desktop Notification When Claude Stops”Problem: Claude is running a long task, you switch focus, you miss when it finishes. Solution: Send a system notification when Claude stops.
macOS:
{ "hooks": [ { "event": "Stop", "run": "sh", "args": ["-c", "osascript -e 'display notification \"Task complete\" with title \"Claude Code\" sound name \"Glass\"'"] } ]}Linux (notify-send):
{ "hooks": [ { "event": "Stop", "run": "sh", "args": ["-c", "notify-send 'Claude Code' 'Task complete' --icon=dialog-information"] } ]}Windows (PowerShell):
{ "hooks": [ { "event": "Stop", "run": "powershell", "args": ["-Command", "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show('Task complete', 'Claude Code')"] } ]}Hook 6: Log Every File Claude Edits
Section titled “Hook 6: Log Every File Claude Edits”Problem: You want an audit trail of every file Claude changes. Solution: Append to a log file on every edit.
{ "hooks": [ { "event": "PostToolUse", "if": { "tool": "Edit" }, "run": "sh", "args": ["-c", "echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) EDIT $file_path\" >> .claude/edit-log.txt"], "continueOnError": true } ]}After a session, .claude/edit-log.txt looks like:
2024-01-15T10:23:44Z EDIT src/services/payment.ts2024-01-15T10:23:51Z EDIT src/validators/payment.ts2024-01-15T10:24:03Z EDIT tests/services/payment.test.tsUseful for audits, post-mortems, and understanding what Claude did in a long session.
Hook 7: ESLint Auto-Fix on Write
Section titled “Hook 7: ESLint Auto-Fix on Write”Problem: Claude creates a new file that has lint errors. Solution: Auto-fix with ESLint whenever a new file is written.
{ "hooks": [ { "event": "PostToolUse", "if": { "tool": "Write", "path": "src/**/*.ts" }, "run": "sh", "args": ["-c", "npx eslint --fix \"$file_path\" 2>/dev/null || true"], "continueOnError": true } ]}Hook 8: Reload Environment When .env Changes
Section titled “Hook 8: Reload Environment When .env Changes”Problem: Claude edits .env but the running dev server doesn’t see the new values.
Solution: Trigger a server restart when .env changes.
{ "hooks": [ { "event": "FileChanged", "if": { "path": ".env" }, "run": "sh", "args": ["-c", "pkill -f 'node.*dev' 2>/dev/null; echo 'Dev server stopped — restart manually with npm run dev'"], "continueOnError": true } ]}Hook 9: Prevent Committing to Main
Section titled “Hook 9: Prevent Committing to Main”Problem: You never want Claude to commit directly to the main branch.
Solution: PreToolUse hook that checks the current branch before any git commit.
{ "hooks": [ { "event": "PreToolUse", "if": { "tool": "Bash", "command": "git commit*" }, "run": "bash", "args": [ "-c", "branch=$(git branch --show-current); if [ \"$branch\" = 'main' ] || [ \"$branch\" = 'master' ]; then echo \"ERROR: Cannot commit directly to $branch\" >&2; exit 2; fi" ] } ]}What happens:
- Claude tries to run
git commit -m "..." - Hook checks:
git branch --show-current→main - Exits 2 → commit is denied
- Claude sees the error and offers to create a branch instead
Hook 10: Generate Prisma Client After Schema Changes
Section titled “Hook 10: Generate Prisma Client After Schema Changes”Problem: Claude edits prisma/schema.prisma but doesn’t regenerate the client, causing type errors.
Solution: Auto-regenerate when schema changes.
{ "hooks": [ { "event": "PostToolUse", "if": { "tool": "Edit", "path": "prisma/schema.prisma" }, "run": "sh", "args": ["-c", "npx prisma generate 2>&1 | tail -5"], "continueOnError": false } ]}continueOnError: false means if prisma generate fails (e.g. invalid schema), Claude is stopped immediately.
Hook 11: Session Start — Print Current Status
Section titled “Hook 11: Session Start — Print Current Status”Problem: You want Claude to know the current state of the repo at the start of every session. Solution: Run status commands at session start and inject output.
{ "hooks": [ { "event": "SessionStart", "run": "sh", "args": ["-c", "echo '=== Git Status ===' && git status --short && echo '=== Recent Commits ===' && git log --oneline -5"], "continueOnError": true } ]}This output is shown to Claude at session start, so it immediately knows what branch you’re on and what’s changed.
Hook 12: Validate JSON Files After Editing
Section titled “Hook 12: Validate JSON Files After Editing”Problem: Claude edits a JSON config file and introduces a syntax error. Solution: Validate JSON after every JSON edit.
{ "hooks": [ { "event": "PostToolUse", "if": { "tool": "Edit", "path": "**/*.json" }, "run": "sh", "args": ["-c", "python3 -m json.tool \"$file_path\" > /dev/null && echo 'JSON valid' || (echo 'JSON INVALID' >&2; exit 1)"], "continueOnError": false } ]}continueOnError: false + exit 1 stops Claude immediately when it writes broken JSON.
Full Production settings.json with Hooks
Section titled “Full Production settings.json with Hooks”{ "permissions": { "allow": ["Bash(npm *)", "Bash(git *)", "Read", "Edit", "Write"] }, "hooks": [ { "event": "PostToolUse", "if": { "tool": "Edit", "path": "**/*.ts" }, "run": "sh", "args": ["-c", "prettier --write \"$file_path\" 2>/dev/null || true"], "continueOnError": true }, { "event": "PostToolUse", "if": { "tool": "Edit", "path": "**/*.ts" }, "run": "sh", "args": ["-c", "npx tsc --noEmit 2>&1 | head -10 || true"], "continueOnError": true }, { "event": "PreToolUse", "if": { "tool": "Edit" }, "run": "bash", "args": ["-c", "case \"$file_path\" in prisma/migrations/*|.env*) echo \"BLOCKED: $file_path\" >&2; exit 2;; esac"] }, { "event": "PreToolUse", "if": { "tool": "Bash", "command": "git commit*" }, "run": "bash", "args": ["-c", "branch=$(git branch --show-current 2>/dev/null); [ \"$branch\" = 'main' ] && echo 'ERROR: no commits to main' >&2 && exit 2 || exit 0"] }, { "event": "Stop", "run": "sh", "args": ["-c", "echo '[Claude Code] Session ended at $(date)'"], "continueOnError": true } ]}🧠 Quick Recall
Section titled “🧠 Quick Recall”- Trick: “Post formats, Pre guards, Stop verifies.”
👨🏫 Teach It
Section titled “👨🏫 Teach It”- Do: everyone installs one recipe right now (format-on-edit is the gateway drug).
- They’ll trip on: silent hook failures — debug with
claude --debug.
Learning path: ⬅ 23-hooks.md · Index · ➡ 25-mcp-servers.md
Written by Fenil Patel