Module 5: Skills and Hooks: automating your workflow
Hooks: Claude Code's Lifecycle Events
Hooks: Claude Code's Lifecycle Events
Overview
Skills give you slash commands for on-demand tasks. Hooks do something different: they run scripts automatically at specific points in Claude Code's lifecycle. You don't invoke them — they fire on their own when the configured event happens.
Think of hooks as sensors on a production line. When Claude writes a file, a hook can run the linter automatically. When Claude runs a command, a hook can check it isn't destructive. When Claude finishes a task, a hook can run the tests. All without you doing a thing.
This capsule covers the 5 lifecycle events, how to configure hooks in settings.json, how to write hook scripts, how to use matchers to filter tools, and how to control the flow with return codes.
What Hooks are
A hook is an association between a Claude Code lifecycle event and a script that runs when that event fires.
┌──────────────────────────────────────────────────────────────┐
│ LIFECYCLE │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ PreToolUse │──▶ │ Check │──▶ Proceed? │
│ └──────────────┘ │ before │ Yes → continue │
│ │ │ acting │ No → block │
│ ▼ └──────────────┘ │
│ ┌──────────────┐ │
│ │ [Claude uses │ │
│ │ the tool] │ │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ PostToolUse │──▶ │ Validate │──▶ Log/format/test │
│ └──────────────┘ │ after │ │
│ │ │ acting │ │
│ ▼ └──────────────┘ │
│ ┌──────────────┐ │
│ │Notification │──▶ When Claude sends a notification │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Stop │──▶ When the main agent stops │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │SubagentStop │──▶ When a subagent stops │
│ └──────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
Every available hook event
Claude Code exposes 22+ events organized by category. Here's the full list as of April 2026:
Session Lifecycle:
| Event | When it fires |
|---|---|
SessionStart | When a session starts or resumes |
SessionEnd | When a session ends |
InstructionsLoaded | When CLAUDE.md or .claude/rules/*.md is loaded |
Per-Turn Events:
| Event | When it fires |
|---|---|
UserPromptSubmit | When you submit a prompt, before Claude processes it |
Stop | When Claude finishes responding |
StopFailure | When the turn ends due to an API error |
Tool Execution (Agentic Loop):
| Event | When it fires |
|---|---|
PreToolUse | Before a tool runs (can block it) |
PostToolUse | After a tool completes successfully |
PostToolUseFailure | After a tool fails |
PermissionRequest | When the permission dialog appears |
PermissionDenied | When the auto mode classifier denies a tool |
Agent Team / Subagents:
| Event | When it fires |
|---|---|
SubagentStart | When a subagent is launched |
SubagentStop | When a subagent finishes |
TeammateIdle | When an agent team teammate is about to go idle |
TaskCreated | When a task is created via TaskCreate |
TaskCompleted | When a task is marked complete |
File & Configuration Events:
| Event | When it fires |
|---|---|
ConfigChange | When a configuration file changes mid-session |
CwdChanged | When the working directory changes |
FileChanged | When a watched file changes on disk |
WorktreeCreate | When a worktree is created |
WorktreeRemove | When a worktree is removed |
Context Management:
| Event | When it fires |
|---|---|
PreCompact | Before context compaction |
PostCompact | After compaction completes |
MCP Integration:
| Event | When it fires |
|---|---|
Elicitation | When an MCP server asks for user input |
ElicitationResult | After the user responds |
Others:
| Event | When it fires |
|---|---|
Notification | When Claude Code sends a notification |
The 5 events you'll use most
Out of all the events above, these are the ones that dominate daily use. We'll cover each in depth:
| Event | When it fires | Typical use |
|---|---|---|
| PreToolUse | Before Claude uses a tool | Gating: block actions that aren't allowed |
| PostToolUse | After Claude uses a tool | Validation: lint, format, tests |
| UserPromptSubmit | Before Claude processes your prompt | Inject context, validate prompts |
| SessionStart | When a session starts/resumes | Load initial context, secrets, variables |
| Stop | When Claude finishes responding | Final tests, notifications, summaries |
PreToolUse and PostToolUse are the most used, and they support matcher to filter by tool. UserPromptSubmit is key for AI pipelines — you can inject dynamic context. SessionStart is for setup (loading env vars, secrets). Stop lets you run final checks.
Important newer events (post-2025):
UserPromptSubmit→ validate/enrich prompts before they're processedPreCompact/PostCompact→ hook into context window compactionSessionStart/SessionEnd→ session setup/teardownInstructionsLoaded→ tracking when CLAUDE.md loadsElicitation→ MCP integration when a server asks for input
Where they're configured
Hooks are configured in Claude Code's settings.json files:
- Global:
~/.claude/settings.json(applies to all your projects) - Project:
.claude/settings.json(shared with the team via git) - Personal:
.claude/settings.local.json(just you, in.gitignore)
The configuration uses a hooks key with event names as sub-keys. Each hook has:
type: The handler type (command,http,prompt, oragent)command/url/ etc.: Depending on the typematcher(optional, for PreToolUse and PostToolUse): Filters by a specific toolif(optional): An extra condition for running the hooktimeout(optional): Timeout in secondsstatusMessage(optional): A message shown while it runs
The 4 Hook Handler types
Claude Code supports 4 handler types for hooks:
| Type | Description | When to use it |
|---|---|---|
command | Runs a shell command | Local scripts, CLIs, the default option |
http | POST request to an HTTP endpoint | Integrating with external APIs (Slack, Datadog, your own services) |
prompt | A single-turn LLM evaluation | Decisions that need judgment (is this prompt safe?) |
agent | A check run by a subagent | Complex validations that need context |
Example with the command handler (the most common):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/script.sh",
"if": "Bash(rm *)",
"timeout": 30
}
]
}
]
}
}
Example with the http handler:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "http",
"url": "https://my-service.com/claude-done",
"timeout": 10
}
]
}
]
}
}
Example with the prompt handler (an LLM evaluates):
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Does this prompt contain credentials or sensitive data? Answer only 'yes' or 'no'."
}
]
}
]
}
}
Important hook behavior
- The hook's stdout is shown to Claude as user feedback. That means you can communicate information to Claude from your scripts.
- On PreToolUse, a hook that returns a non-zero exit code blocks the tool from running. This is how you build safety gates.
- On other events, a non-zero exit code is reported as an error but doesn't block the operation.
Visibility of hook output
- stdout → Claude sees it as user feedback. Use it to communicate results.
- stderr → Recorded in logs, but Claude doesn't see it directly.
- Exit code 0 → Hook succeeded, the operation continues.
- Exit code ≠ 0 → For PreToolUse, BLOCKS the tool. For PostToolUse, it's just reported.
Configuration format
Each event receives an array of hooks. A complete example:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"command": "./scripts/pre-write-check.sh"
}
],
"PostToolUse": [
{
"matcher": "Write",
"command": "./scripts/post-write-validate.sh"
}
],
"Notification": [
{
"command": "echo 'Claude sent a notification'"
}
],
"Stop": [
{
"command": "npm test"
}
],
"SubagentStop": [
{
"command": "echo 'Subagent finished'"
}
]
}
}
Tool Matchers
Matchers filter which tool should trigger a PreToolUse or PostToolUse hook. Without a matcher, the hook runs for every tool.
Tools available for matching
| Matcher | What it intercepts |
|---|---|
Write | Claude writes or edits a file |
Execute | Claude runs a terminal command |
Read | Claude reads a file |
Grep | Claude searches inside files |
Glob | Claude searches for files by pattern |
WebFetch | Claude makes an HTTP request |
Example: a hook only for file writes
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "npx eslint --fix $CLAUDE_FILE_PATH"
}
]
}
}
This hook only runs when Claude writes a file. It doesn't run when it reads files, executes commands, or searches the codebase.
Multiple hooks for the same event
You can configure multiple hooks for a single event:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "npx prettier --write $CLAUDE_FILE_PATH"
},
{
"matcher": "Write",
"command": "npx eslint $CLAUDE_FILE_PATH"
},
{
"matcher": "Execute",
"command": "echo 'Command executed: $CLAUDE_TOOL_INPUT'"
}
]
}
}
Hooks run in sequential order.
Environment variables in Hooks
When a hook runs, Claude Code passes information about the action through environment variables:
| Variable | Description | Available in |
|---|---|---|
CLAUDE_FILE_PATH | Path of the file being written/read | Write, Read |
CLAUDE_TOOL_INPUT | The tool's full input (JSON) | All |
CLAUDE_TOOL_NAME | The tool's name (Write, Execute, etc.) | All |
CLAUDE_SESSION_ID | The current session's ID | All |
CLAUDE_PROJECT_DIR | The project's root directory | All |
Example: using environment variables
#!/bin/bash
# scripts/post-write-validate.sh
echo "File modified: $CLAUDE_FILE_PATH"
echo "Tool: $CLAUDE_TOOL_NAME"
# Only run lint on TypeScript files
if [[ "$CLAUDE_FILE_PATH" == *.ts ]] || [[ "$CLAUDE_FILE_PATH" == *.tsx ]]; then
npx eslint "$CLAUDE_FILE_PATH"
fi
Return codes: controlling the flow
The hook script's return code determines what Claude Code does next:
| Return code | Effect on PreToolUse | Effect on PostToolUse |
|---|---|---|
| 0 (success) | Claude proceeds with the action | Claude continues normally |
| Non-0 (error) | Claude blocks the action | Claude sees the error and can act on it |
This is fundamental for PreToolUse: you can use hooks to block actions that don't meet your conditions.
Example: blocking commits without tests
#!/bin/bash
# scripts/pre-commit-check.sh
if [[ "$CLAUDE_TOOL_INPUT" == *"git commit"* ]]; then
# Run tests before allowing the commit
npm test --silent
if [ $? -ne 0 ]; then
echo "ERROR: Tests failing. Fix tests before committing."
exit 1 # Blocks the commit
fi
fi
exit 0 # Allows the action
{
"hooks": {
"PreToolUse": [
{
"matcher": "Execute",
"command": "./scripts/pre-commit-check.sh"
}
]
}
}
If the tests fail, the hook returns 1 and Claude does not run the commit. Claude sees the error message and can decide to fix the tests first.
PreToolUse: gating before acting
PreToolUse runs before Claude uses a tool. It's your chance to validate, check, or block an action.
Basic example: logging actions
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"command": "echo 'Claude is about to write: $CLAUDE_FILE_PATH'"
}
]
}
}
Intermediate example: validating a naming convention
#!/bin/bash
# scripts/check-naming.sh
FILE="$CLAUDE_FILE_PATH"
# Check that component files use PascalCase
if [[ "$FILE" == src/components/*.tsx ]]; then
BASENAME=$(basename "$FILE" .tsx)
if [[ ! "$BASENAME" =~ ^[A-Z] ]]; then
echo "ERROR: Components must use PascalCase. '$BASENAME' does not."
exit 1
fi
fi
# Check that test files carry the test_ prefix
if [[ "$FILE" == tests/*.py ]]; then
BASENAME=$(basename "$FILE")
if [[ ! "$BASENAME" == test_* ]]; then
echo "ERROR: Tests must start with 'test_'. '$BASENAME' does not."
exit 1
fi
fi
exit 0
If Claude tries to create src/components/userProfile.tsx (lowercase), the hook blocks the write and Claude sees the error. Claude can then fix the name to UserProfile.tsx.
Advanced example: preventing dangerous operations
#!/bin/bash
# scripts/safety-gate.sh
INPUT="$CLAUDE_TOOL_INPUT"
# Block rm -rf in important directories
if [[ "$INPUT" == *"rm -rf"* ]]; then
for dir in "src" "lib" "app" "node_modules" ".git"; do
if [[ "$INPUT" == *"$dir"* ]]; then
echo "BLOCKED: rm -rf is not allowed in critical directories ($dir)"
exit 1
fi
done
fi
# Block git push --force to main
if [[ "$INPUT" == *"git push"*"--force"* ]] && [[ "$INPUT" == *"main"* ]]; then
echo "BLOCKED: force push to main is not allowed"
exit 1
fi
exit 0
PostToolUse: validating after acting
PostToolUse runs after Claude uses a tool. It's ideal for validations, formatting, and automatic checks.
Basic example: auto-format with Prettier
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "npx prettier --write $CLAUDE_FILE_PATH 2>/dev/null || true"
}
]
}
}
Every time Claude writes a file, Prettier formats it automatically.
Intermediate example: lint after a write
#!/bin/bash
# scripts/post-write-lint.sh
FILE="$CLAUDE_FILE_PATH"
# Only lint JS/TS files
case "$FILE" in
*.js|*.jsx|*.ts|*.tsx)
echo "Running ESLint on $FILE..."
npx eslint "$FILE" 2>&1
;;
*.py)
echo "Running ruff on $FILE..."
ruff check "$FILE" 2>&1
;;
*.css|*.scss)
echo "Running Stylelint on $FILE..."
npx stylelint "$FILE" 2>&1
;;
esac
If the linter finds errors, Claude sees them in the output and can fix them automatically on its next action.
Advanced example: automatic tests after changes
#!/bin/bash
# scripts/post-write-test.sh
FILE="$CLAUDE_FILE_PATH"
# If a source file was modified, find and run its test
if [[ "$FILE" == src/*.ts ]] || [[ "$FILE" == src/*.tsx ]]; then
# Build the test's path
TEST_FILE="${FILE/src\//tests/test_}"
TEST_FILE="${TEST_FILE/.tsx/.test.tsx}"
TEST_FILE="${TEST_FILE/.ts/.test.ts}"
if [ -f "$TEST_FILE" ]; then
echo "Running related tests: $TEST_FILE"
npx vitest run "$TEST_FILE" --reporter=verbose 2>&1
fi
fi
# If a test was modified, run that test directly
if [[ "$FILE" == *test* ]] || [[ "$FILE" == *spec* ]]; then
echo "Running modified test: $FILE"
npx vitest run "$FILE" --reporter=verbose 2>&1
fi
Stop: final checks
Stop runs when Claude Code's main agent stops. It's ideal for global checks and notifications at the end of a work session.
Basic example
{
"hooks": {
"Stop": [
{
"command": "echo 'Agent stopped. Checking state...'"
}
]
}
}
Intermediate example: post-task summary
#!/bin/bash
# scripts/stop-summary.sh
echo "=== Post-task summary ==="
echo "Modified files:"
git diff --name-only 2>/dev/null | head -10
echo ""
echo "Tests:"
npm test --silent 2>/dev/null && echo "All passing" || echo "Some tests failing"
echo "========================="
Advanced example: a notification with the full state
#!/bin/bash
# scripts/on-stop.sh
MODIFIED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')
echo "=== Agent stopped ==="
echo "Modified files: $MODIFIED"
echo "New files: $UNTRACKED"
# Type checking
if [ -f "tsconfig.json" ]; then
npx tsc --noEmit 2>&1 | tail -1
fi
# Lint
npx eslint src/ --quiet 2>&1 | tail -3
echo "========================="
Notification: custom alerts
Notification runs when Claude sends a notification. It's useful for logging or custom alerts.
Basic example
{
"hooks": {
"Notification": [
{
"command": "echo 'Claude sent a notification' >> ~/claude-notifications.log"
}
]
}
}
SubagentStop: validating subagent results
SubagentStop runs when a subagent (invoked by the main agent) finishes its work. It's useful for validating a subagent's partial results.
Basic example
{
"hooks": {
"SubagentStop": [
{
"command": "echo 'Subagent completed its task'"
}
]
}
}
Comparisons and decisions
Hooks vs doing everything manually
| Aspect | Manual | With Hooks |
|---|---|---|
| Consistency | Depends on you remembering | Always runs |
| Speed | You have to type/paste commands | Automatic |
| Human error | Frequent (forgetting lint, tests) | Eliminated |
| Initial setup | None | 10-15 minutes |
| Overhead per action | Variable (depends on what you remember) | Fixed (the script always runs) |
Claude Code hooks vs git hooks
| Aspect | Claude Code Hooks | Git Hooks |
|---|---|---|
| Events | PreToolUse, PostToolUse, Notification, Stop, SubagentStop | pre-commit, post-commit, pre-push, etc. |
| When they run | During a Claude Code session | On git operations |
| Context | They know which tool Claude used and which file it modified | They only know there's a commit/push |
| Who runs them | Claude Code (the agent) | Git (the tool) |
| Complementary | ✅ Yes — they validate during development | ✅ Yes — they validate on the way into git |
They don't compete — they complement each other. Claude Code hooks catch problems during development. Git hooks catch them at commit time.
When to use each event
| Need | Event |
|---|---|
| Block an action before it happens | PreToolUse |
| Validate the result of an action | PostToolUse |
| Auto-format code after a write | PostToolUse + Write matcher |
| Prevent destructive commands | PreToolUse + Execute matcher |
| Run related tests | PostToolUse + Write matcher |
| Global check when work ends | Stop |
| Customize alerts or logging | Notification |
| Validate subagent results | SubagentStop |
Common patterns
Pattern 1: Post-write validation pipeline
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "npx prettier --write $CLAUDE_FILE_PATH 2>/dev/null"
},
{
"matcher": "Write",
"command": "npx eslint $CLAUDE_FILE_PATH 2>/dev/null"
}
]
}
}
Every file written goes through format → lint automatically.
Pattern 2: Safety gate for commands
{
"hooks": {
"PreToolUse": [
{
"matcher": "Execute",
"command": "./scripts/safety-gate.sh"
}
]
}
}
Every command passes through a safety filter before it runs.
Pattern 3: Continuous testing
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "./scripts/run-related-tests.sh"
}
]
}
}
Every time Claude writes code, the related tests run.
Pattern 4: A summary when work ends
{
"hooks": {
"Stop": [
{
"command": "./scripts/stop-summary.sh"
}
]
}
}
Every time Claude finishes its work, a summary script runs and shows the project's final state.
Pitfalls and edge cases
Pitfall 1: Slow hooks
The mistake: A PostToolUse hook that runs the entire test suite after every write.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "npm test"
}
]
}
}
The problem: If npm test takes 30 seconds and Claude writes 10 files, that's 5 minutes of waiting on hooks alone.
The fix: Run only the related tests, not the whole suite:
#!/bin/bash
# Only tests for the modified file
npx vitest run --reporter=dot "$CLAUDE_FILE_PATH" 2>/dev/null || true
Or move the full suite to the Stop hook (it runs once, when Claude finishes).
Pitfall 2: Infinite loops
The mistake: A PostToolUse hook that modifies the file Claude just wrote, which triggers another PostToolUse, which modifies the file, which triggers another PostToolUse...
The problem: An infinite loop.
The fix: Claude Code has loop protections built in, but design your hooks to be idempotent. A format hook (Prettier) is safe because formatting an already-formatted file produces no changes.
Preventing infinite loops
What happens if a PostToolUse hook modifies a file? Does that trigger another PostToolUse?
Answer: Claude Code has built-in loop protection. Hooks don't recursively trigger other hooks. If your PostToolUse hook uses the Write tool internally, that write does NOT trigger another PostToolUse cycle.
Good practice: Design your hooks as idempotent operations — they produce the same result no matter how many times they run.
Pitfall 3: Hooks that block legitimate operations
The mistake: A PreToolUse that blocks rm without considering context.
if [[ "$INPUT" == *"rm"* ]]; then
exit 1 # Blocks EVERYTHING containing "rm"
fi
The problem: It blocks legitimate operations like npm run format (which may contain "rm" in its internals).
The fix: Be specific about the patterns you block:
if [[ "$INPUT" =~ ^rm\ -rf\ / ]]; then
exit 1 # Only blocks rm -rf at the system root
fi
Pitfall 4: Scripts without execute permissions
The mistake: Creating a hook script and forgetting to make it executable.
The fix:
chmod +x scripts/post-write-validate.sh
Pitfall 5: Hooks that don't handle errors
The mistake: A hook that fails with an unhandled error and blocks the whole session.
The fix: Use || true for hooks that shouldn't block:
{
"command": "npx eslint $CLAUDE_FILE_PATH 2>/dev/null || true"
}
The || true ensures the hook always returns 0 (success), even if the linter fails. Use this for informational hooks (where you want to see the error but not block).
Complete worked example
Scenario: you're setting up a TypeScript + React project with a full set of quality hooks.
Script structure
scripts/
├── hooks/
│ ├── pre-write-check.sh
│ ├── post-write-validate.sh
│ └── on-stop.sh
settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"command": "./scripts/hooks/pre-write-check.sh"
}
],
"PostToolUse": [
{
"matcher": "Write",
"command": "./scripts/hooks/post-write-validate.sh"
}
],
"Stop": [
{
"command": "./scripts/hooks/on-stop.sh"
}
]
}
}
Scripts
pre-write-check.sh:
#!/bin/bash
FILE="$CLAUDE_FILE_PATH"
# Make sure nothing gets written into node_modules
if [[ "$FILE" == *node_modules* ]]; then
echo "BLOCKED: don't modify files in node_modules"
exit 1
fi
# Check component naming
if [[ "$FILE" == src/components/*.tsx ]]; then
BASENAME=$(basename "$FILE" .tsx)
if [[ ! "$BASENAME" =~ ^[A-Z] ]]; then
echo "BLOCKED: components must use PascalCase ($BASENAME)"
exit 1
fi
fi
exit 0
post-write-validate.sh:
#!/bin/bash
FILE="$CLAUDE_FILE_PATH"
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx)
npx prettier --write "$FILE" 2>/dev/null
npx eslint "$FILE" --quiet 2>/dev/null || true
;;
*.css|*.scss)
npx prettier --write "$FILE" 2>/dev/null
;;
esac
on-stop.sh:
#!/bin/bash
echo "=== Final check ==="
npx tsc --noEmit 2>&1 | tail -3
npm test --silent 2>&1 | tail -3
echo "================================="
Practice exercises
Exercise 1: Basic — Your first PostToolUse hook
Configure a PostToolUse hook that logs a line every time Claude writes a file.
Requirements:
- Only fires when Claude uses the Write tool
- Prints the path of the file written
- Must not block the operation
Solution
Add to .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "echo \"File written: $CLAUDE_FILE_PATH\""
}
]
}
}
Exercise 2: Basic — A PostToolUse hook for formatting
Configure a hook that runs Prettier automatically after every write.
Requirements:
- Only on JS/TS/CSS files
- Must not block if Prettier fails
- Must be quiet (no Prettier output when there are no changes)
Solution
Create scripts/hooks/auto-format.sh:
#!/bin/bash
FILE="$CLAUDE_FILE_PATH"
case "$FILE" in
*.js|*.jsx|*.ts|*.tsx|*.css|*.scss|*.json)
npx prettier --write "$FILE" --log-level=warn 2>/dev/null || true
;;
esac
chmod +x scripts/hooks/auto-format.sh
In .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "./scripts/hooks/auto-format.sh"
}
]
}
}
Exercise 3: Intermediate — A PreToolUse safety gate
Create a hook that blocks dangerous commands before Claude runs them.
Requirements:
- Block
rm -rfin directories likesrc/,.git/,node_modules/ - Block
git push --forcetomainormaster - Allow everything else
- Show a clear message when something is blocked
Solution
Create scripts/hooks/safety-gate.sh:
#!/bin/bash
INPUT="$CLAUDE_TOOL_INPUT"
# Block rm -rf in critical directories
if [[ "$INPUT" == *"rm -rf"* ]]; then
for dir in "src" ".git" "node_modules" "lib" "app"; do
if [[ "$INPUT" == *"$dir"* ]]; then
echo "⛔ BLOCKED: rm -rf in a critical directory ($dir)"
exit 1
fi
done
fi
# Block force push to main/master
if [[ "$INPUT" == *"git push"*"--force"* ]]; then
if [[ "$INPUT" == *"main"* ]] || [[ "$INPUT" == *"master"* ]]; then
echo "⛔ BLOCKED: force push to main/master is not allowed"
exit 1
fi
fi
exit 0
chmod +x scripts/hooks/safety-gate.sh
In .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Execute",
"command": "./scripts/hooks/safety-gate.sh"
}
]
}
}
Exercise 4: Intermediate — A complete post-write pipeline
Configure a pipeline that runs format → lint → type-check after every TypeScript file write.
Requirements:
- Prettier for formatting
- ESLint for linting
- The TypeScript compiler (tsc) for type checking
- Only on .ts and .tsx files
- If lint or type-check fail, Claude should see the errors (but not be blocked)
Solution
Create scripts/hooks/post-write-pipeline.sh:
#!/bin/bash
FILE="$CLAUDE_FILE_PATH"
# Only process TypeScript files
case "$FILE" in
*.ts|*.tsx)
echo "--- Validation pipeline ---"
# Step 1: Format
npx prettier --write "$FILE" --log-level=warn 2>/dev/null
# Step 2: Lint
LINT_OUTPUT=$(npx eslint "$FILE" --quiet 2>&1)
if [ -n "$LINT_OUTPUT" ]; then
echo "⚠️ ESLint:"
echo "$LINT_OUTPUT"
fi
# Step 3: Type check
TSC_OUTPUT=$(npx tsc --noEmit 2>&1 | grep "$FILE")
if [ -n "$TSC_OUTPUT" ]; then
echo "⚠️ TypeScript:"
echo "$TSC_OUTPUT"
fi
echo "--- Pipeline complete ---"
;;
esac
exit 0
chmod +x scripts/hooks/post-write-pipeline.sh
Exercise 5: Advanced — A complete hook system
Configure all 5 hook events for a real project, with a script for each one.
Requirements:
- PreToolUse (Write): validate naming conventions
- PreToolUse (Execute): safety gate for dangerous commands
- PostToolUse (Write): format + lint
- Stop: run tests + summary
- Notification: log notifications
Solution
Use the configuration from this capsule's complete worked example as your base. Adapt the scripts to your project's conventions (language, framework, lint/test tooling).
Verify that:
- Every script is executable (
chmod +x) - The hooks run correctly (test each one)
- The PreToolUse hooks don't block legitimate operations
- The PostToolUse hooks aren't too slow
- The Stop hook gives you useful information when work ends
Summary
What you learned in this capsule:
- Hooks are scripts that run automatically on Claude Code lifecycle events
- The 5 most common events: PreToolUse, PostToolUse, Notification, Stop, SubagentStop (Claude Code exposes 20+ events in total — see the official docs)
- Configuration lives in
settings.json(global~/.claude/settings.json, project.claude/settings.json, or personal.claude/settings.local.json) - Matchers filter which tool triggers the hook (Write, Execute, Read, etc.) -- only for PreToolUse and PostToolUse
- Return codes: 0 = proceed, non-0 = block (on PreToolUse)
- The hook's stdout is shown to Claude as user feedback
- Environment variables:
CLAUDE_FILE_PATH,CLAUDE_TOOL_INPUT,CLAUDE_TOOL_NAMEgive the hook context - Critical pitfalls: slow hooks, infinite loops, over-blocking, scripts without permissions
Next capsule: 04 - Automating validations -- how to build a complete quality pipeline using hooks.
Additional resources
Official documentation
- Hooks — Complete documentation for hooks and lifecycle events
- Settings — Where to configure hooks (global, project, local)
- Plugins Reference — Hooks as part of the plugin system
Complementary
- Claude Code Best Practices — How hooks fit into the workflow
- CLI Reference — Commands you can use inside hooks
- Interactive Mode — How permissions interact with hooks