Skip to content

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.


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


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:

  1. Claude calls Edit("src/services/user.ts", ...)
  2. File is updated
  3. Hook fires: prettier --write "src/services/user.ts"
  4. File is formatted
  5. 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:

  1. Claude edits src/services/payment.ts
  2. Hook fires: npx tsc --noEmit
  3. If type error: output goes back to Claude — it sees and fixes the error
  4. If clean: nothing visible, Claude continues

head -20 limits output to avoid flooding context with hundreds of errors.


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 call
  • 1 — error, stop Claude
  • 2 — deny this specific tool call (Claude sees “permission denied” and adjusts)

What happens:

  1. Claude tries to edit prisma/migrations/20240101_init.sql
  2. Hook fires, detects it’s a migration file
  3. Exits with code 2
  4. Tool call is denied
  5. 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:

  1. Claude writes tests/services/payment.test.ts
  2. Hook fires: runs only that test file
  3. Output (pass/fail) goes back to Claude
  4. 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')"]
}
]
}

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.ts
2024-01-15T10:23:51Z EDIT src/validators/payment.ts
2024-01-15T10:24:03Z EDIT tests/services/payment.test.ts

Useful for audits, post-mortems, and understanding what Claude did in a long session.


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
}
]
}

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:

  1. Claude tries to run git commit -m "..."
  2. Hook checks: git branch --show-currentmain
  3. Exits 2 → commit is denied
  4. 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.


{
"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
}
]
}
  • Trick:Post formats, Pre guards, Stop verifies.”
  • 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