Module 5: Skills and Hooks: automating your workflow
Automating validations with Hooks
Automating validations with Hooks
Overview
In the previous capsule you learned the mechanics of hooks: the 5 events, matchers, return codes, environment variables. Now comes the practical payoff: building an automated quality pipeline that validates Claude Code's work in real time.
The idea is simple but powerful: every time Claude writes code, a set of validations runs automatically. Linting, formatting, type checking, tests. If something fails, Claude sees it and fixes it before you have to step in. It's like having CI/CD that runs on every keystroke instead of every push.
This capsule covers 5 validation patterns, how to chain them into a pipeline, comparisons with CI/CD and git hooks, and the pitfalls you need to avoid so your hooks don't turn into a bottleneck.
The power of automatic validations
The problem without hooks
Without hooks, the quality flow depends on your memory:
Claude writes a file
→ You: "Run eslint"
→ Claude runs eslint → 3 errors
→ You: "Fix them"
→ Claude fixes them
→ You: "Now run prettier"
→ Claude runs prettier
→ You: "Run the tests"
→ Claude runs the tests → 1 failing
→ You: "Fix the test"
→ ...
That's 6-8 manual interactions per file. If Claude modifies 5 files in one task, that's 30-40 interactions spent purely on validation.
The flow with hooks
Claude writes a file
→ [PostToolUse hook] Prettier formats it automatically
→ [PostToolUse hook] ESLint validates → errors visible to Claude
→ Claude sees the errors and fixes them
→ [PostToolUse hook] ESLint validates again → 0 errors
→ [PostToolUse hook] Related tests pass
→ Next file...
0 manual validation interactions. Claude self-corrects based on the hooks' feedback.
The key: an automatic feedback loop
The most powerful thing about hooks isn't that they run validations — it's that they create a feedback loop. Claude sees the hook's output, interprets the errors, and fixes them on its next action. That's real-time self-correction.
┌─────────┐ ┌──────────┐ ┌───────────┐
│ Claude │────▶│ Hook │────▶│ Output │
│ writes │ │ validates│ │ (errors │
│ │ │ │ │ or ✅) │
└─────────┘ └──────────┘ └─────┬─────┘
▲ │
│ FEEDBACK LOOP │
└─────────────────────────────────┘
Claude reads the output and fixes it
Pattern 1: Lint on every write
The most common and most impactful pattern. Every time Claude writes a file, the linter runs automatically.
Basic configuration
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "./scripts/hooks/lint-on-write.sh"
}
]
}
}
The hook script
#!/bin/bash
# scripts/hooks/lint-on-write.sh
FILE="$CLAUDE_FILE_PATH"
case "$FILE" in
*.ts|*.tsx)
npx eslint "$FILE" --quiet 2>&1
;;
*.js|*.jsx)
npx eslint "$FILE" --quiet 2>&1
;;
*.py)
ruff check "$FILE" 2>&1
;;
*.css|*.scss)
npx stylelint "$FILE" --quiet 2>&1
;;
esac
exit 0
Why exit 0 and not the linter's exit code
On PostToolUse, the file is already written. Returning an error code doesn't "undo" the write — it just tells Claude something is wrong. Claude sees the linter's output and can fix it on its next action. That's why we use exit 0: we want Claude to treat the errors as information, not as a block.
With auto-fix
If you'd rather have the linter fix what it can automatically:
#!/bin/bash
FILE="$CLAUDE_FILE_PATH"
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx)
npx eslint "$FILE" --fix --quiet 2>&1
;;
*.py)
ruff check "$FILE" --fix 2>&1
;;
esac
exit 0
--fix fixes the mechanical problems (spacing, imports, etc.). The logical problems get reported so Claude can resolve them.
Pattern 2: Automatic formatting
Prettier (or your formatter of choice) runs automatically after every write. It guarantees that all the code Claude generates follows the project's style.
Configuration
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "npx prettier --write $CLAUDE_FILE_PATH --log-level=warn 2>/dev/null || true"
}
]
}
}
Format before or after lint?
Order matters:
Option A: Format → Lint
Prettier formats → ESLint validates the formatted code
✅ Fewer conflicts (Prettier and ESLint don't contradict each other)
Option B: Lint → Format
ESLint validates → Prettier formats
❌ ESLint may report formatting errors Prettier is about to fix anyway
Recommendation: Format first, lint second:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "npx prettier --write $CLAUDE_FILE_PATH --log-level=warn 2>/dev/null || true"
},
{
"matcher": "Write",
"command": "npx eslint $CLAUDE_FILE_PATH --quiet 2>/dev/null || true"
}
]
}
}
Pattern 3: Tests after implementation
Automatically run the related tests after Claude modifies a code file.
The challenge: which tests do you run?
Running the whole suite on every write is far too slow. The trick is running only the tests related to the modified file.
Strategy: mapping file → test
#!/bin/bash
# scripts/hooks/run-related-tests.sh
FILE="$CLAUDE_FILE_PATH"
# Source files only (not configs, not docs)
if [[ ! "$FILE" == src/* ]] && [[ ! "$FILE" == app/* ]]; then
exit 0
fi
# If it's a test file, run it directly
if [[ "$FILE" == *test* ]] || [[ "$FILE" == *spec* ]]; then
echo "Running test: $FILE"
npx vitest run "$FILE" --reporter=dot 2>&1
exit 0
fi
# Look for the matching test
BASENAME=$(basename "$FILE" | sed 's/\.[^.]*$//')
TEST_FILES=$(find . -name "*${BASENAME}*test*" -o -name "*${BASENAME}*spec*" -o -name "test_${BASENAME}*" 2>/dev/null | head -3)
if [ -n "$TEST_FILES" ]; then
echo "Related tests found:"
for TEST in $TEST_FILES; do
echo " → $TEST"
npx vitest run "$TEST" --reporter=dot 2>&1
done
else
echo "ℹ️ No tests found for $BASENAME"
fi
exit 0
For Python projects
#!/bin/bash
FILE="$CLAUDE_FILE_PATH"
if [[ "$FILE" == *.py ]]; then
BASENAME=$(basename "$FILE" .py)
TEST_FILE="tests/test_${BASENAME}.py"
if [ -f "$TEST_FILE" ]; then
echo "Running: pytest $TEST_FILE -v"
pytest "$TEST_FILE" -v --tb=short 2>&1
fi
fi
exit 0
When to run the full suite
The full suite belongs in Stop rather than PostToolUse:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "./scripts/hooks/run-related-tests.sh"
}
],
"Stop": [
{
"command": "npm test --silent 2>&1 | tail -5"
}
]
}
}
Related tests on every write. The full suite at the end of the task.
Pattern 4: Security validation
Scan code for secrets, credentials, and vulnerabilities before allowing it to be written.
PreToolUse to block an unsafe write
#!/bin/bash
# scripts/hooks/security-check.sh
FILE="$CLAUDE_FILE_PATH"
INPUT="$CLAUDE_TOOL_INPUT"
# Detect possible secrets in the content about to be written
PATTERNS=(
"password\s*=\s*['\"]"
"api_key\s*=\s*['\"]"
"secret\s*=\s*['\"]"
"token\s*=\s*['\"][a-zA-Z0-9]"
"AWS_ACCESS_KEY"
"PRIVATE_KEY"
"-----BEGIN RSA"
"-----BEGIN OPENSSH"
)
for PATTERN in "${PATTERNS[@]}"; do
if echo "$INPUT" | grep -iEq "$PATTERN"; then
echo "⛔ SECURITY: a possible secret/credential was detected"
echo " Pattern: $PATTERN"
echo " File: $FILE"
echo ""
echo " Use environment variables instead of hardcoding secrets."
echo " Example: os.environ['API_KEY'] or process.env.API_KEY"
exit 1
fi
done
exit 0
Configuration
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"command": "./scripts/hooks/security-check.sh"
}
]
}
}
If Claude tries to write a file with password = "admin123", the hook blocks it and suggests using environment variables. Claude sees the message and rewrites using os.environ['PASSWORD'].
PostToolUse for auditing
If you'd rather alert than block:
#!/bin/bash
# scripts/hooks/security-audit.sh
FILE="$CLAUDE_FILE_PATH"
if [ -f "$FILE" ]; then
ISSUES=$(grep -inE "(password|api_key|secret|token)\s*=\s*['\"]" "$FILE" 2>/dev/null)
if [ -n "$ISSUES" ]; then
echo "⚠️ SECURITY ALERT in $FILE:"
echo "$ISSUES"
echo ""
echo "Consider using environment variables for these values."
fi
fi
exit 0
Pattern 5: Type checking
For TypeScript projects, running the compiler after every write catches type errors in real time.
Configuration
#!/bin/bash
# scripts/hooks/type-check.sh
FILE="$CLAUDE_FILE_PATH"
if [[ "$FILE" == *.ts ]] || [[ "$FILE" == *.tsx ]]; then
# Type check only the modified file (faster than the whole project)
ERRORS=$(npx tsc --noEmit 2>&1 | grep "$FILE")
if [ -n "$ERRORS" ]; then
echo "⚠️ Type errors in $FILE:"
echo "$ERRORS"
fi
fi
exit 0
A performance consideration
tsc --noEmit can take several seconds on large projects. Your options:
- Run it only on Stop (not on every write):
{
"hooks": {
"Stop": [
{
"command": "npx tsc --noEmit 2>&1 | head -20"
}
]
}
}
- Use
tsc --noEmitwith--incrementalto cache results:
npx tsc --noEmit --incremental 2>&1 | grep "$FILE"
- Accept the trade-off if the project is small (< 100 files):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "npx tsc --noEmit 2>&1 | grep $CLAUDE_FILE_PATH || true"
}
]
}
}
Building a complete pipeline
The recommended pipeline
For a typical TypeScript/React project, this is the optimal pipeline:
PostToolUse (Write):
1. Prettier (format) → ~100ms per file
2. ESLint (lint) → ~200ms per file
3. Related tests → ~1-3s per file
Stop:
4. tsc --noEmit (types) → ~3-5s total
5. Full test suite → variable
The pipeline configuration
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"command": "./scripts/hooks/security-check.sh"
},
{
"matcher": "Execute",
"command": "./scripts/hooks/safety-gate.sh"
}
],
"PostToolUse": [
{
"matcher": "Write",
"command": "./scripts/hooks/format.sh"
},
{
"matcher": "Write",
"command": "./scripts/hooks/lint.sh"
},
{
"matcher": "Write",
"command": "./scripts/hooks/related-tests.sh"
}
],
"Stop": [
{
"command": "./scripts/hooks/type-check.sh"
},
{
"command": "./scripts/hooks/full-test-suite.sh"
}
]
}
}
Pipeline performance
| Hook | Typical time | Frequency |
|---|---|---|
| Prettier | ~100ms | Per file written |
| ESLint | ~200ms | Per file written |
| Related tests | ~1-3s | Per file written |
| tsc --noEmit | ~3-5s | Per completed task |
| Full suite | ~10-30s | Per completed task |
For a task where Claude writes 5 files, the total overhead is:
- PostToolUse: 5 × (~300ms + ~2s) ≈ ~11.5s spread out across the task
- Stop: ~5s + ~20s = ~25s at the end of the task
That's acceptable. If you go past those numbers, optimize the slowest hooks.
Optimization: conditional hooks with if (March 2026)
A recent feature: hooks can declare a condition with if, using permission rule syntax. The hook only runs when the tool call matches the condition, which cuts overhead in sessions with lots of operations:
{
"hooks": {
"PreToolUse": [{
"hooks": [{
"if": "Bash(git commit *)",
"type": "command",
"command": ".claude/hooks/lint-staged.sh"
}]
}]
}
}
With if: "Bash(git commit *)", lint-staged only runs on git commits — not on every bash call. Useful when you have hooks tied to specific operations.
Comparisons and decisions
Hooks vs CI/CD
| Aspect | Claude Code hooks | CI/CD (GitHub Actions, etc.) |
|---|---|---|
| When it runs | During development, in real time | After the push, remotely |
| Feedback | Immediate (Claude fixes on the spot) | Delayed (minutes after the push) |
| What it validates | Each individual file | The whole project |
| Cost of an error | Low (fixed before the commit) | High (it's already in the repo) |
| Performance | Has to be fast (< 5s per hook) | Can take minutes |
They don't compete — they're complementary layers:
Layer 1: Claude Code hooks → Catch errors during development
Layer 2: Git pre-commit hooks → Catch errors at commit time
Layer 3: CI/CD → Catch errors on push/merge
The earlier you catch an error, the cheaper it is to fix. Claude Code hooks are the first line of defense.
Claude Code hooks vs pre-commit (git)
| Aspect | Claude Code Hooks | Git pre-commit |
|---|---|---|
| Granularity | Per file, per tool | Per commit |
| Agent-aware | ✅ Knows which tool Claude used | ❌ Doesn't know who made the change |
| Feedback to Claude | ✅ Claude sees it and fixes it | ❌ Doesn't interact with Claude |
| Timing | Real time (every write) | At commit time |
When to use lightweight hooks only
If your project is large and the hooks are slow, cut them to the bone:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "npx prettier --write $CLAUDE_FILE_PATH 2>/dev/null || true"
}
],
"Stop": [
{
"command": "npm test --silent 2>&1 | tail -5"
}
]
}
}
Just automatic formatting + tests at the end. No lint or type check in hooks (leave that to CI/CD).
Advanced patterns
Pattern: a hook conditional on the branch
#!/bin/bash
# Only run strict validation on main/develop
BRANCH=$(git branch --show-current 2>/dev/null)
case "$BRANCH" in
main|develop|staging)
# Strict validation
npx eslint "$CLAUDE_FILE_PATH" --max-warnings=0 2>&1
;;
*)
# Relaxed validation (errors only, no warnings)
npx eslint "$CLAUDE_FILE_PATH" --quiet 2>&1 || true
;;
esac
exit 0
Pattern: a hook with a result cache
#!/bin/bash
# Cache lint results to avoid re-running on unchanged files
FILE="$CLAUDE_FILE_PATH"
CACHE_DIR=".claude/cache/lint"
mkdir -p "$CACHE_DIR"
FILE_HASH=$(md5sum "$FILE" 2>/dev/null | cut -d' ' -f1)
CACHE_FILE="$CACHE_DIR/$(echo $FILE | tr '/' '_').hash"
if [ -f "$CACHE_FILE" ] && [ "$(cat $CACHE_FILE)" = "$FILE_HASH" ]; then
exit 0
fi
npx eslint "$FILE" --quiet 2>&1
RESULT=$?
if [ $RESULT -eq 0 ]; then
echo "$FILE_HASH" > "$CACHE_FILE"
fi
exit 0
Pattern: a notification when a long task finishes
#!/bin/bash
# scripts/hooks/notify-done.sh
MODIFIED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
if [ "$MODIFIED" -gt 5 ]; then
echo "📋 Large task completed: $MODIFIED files modified"
echo "Files:"
git diff --name-only 2>/dev/null | head -10
if [ "$MODIFIED" -gt 10 ]; then
echo " ... and $(($MODIFIED - 10)) more"
fi
fi
Pitfalls and edge cases
Pitfall 1: Hooks that are too slow
Symptom: Claude takes a long time between actions. The session feels sluggish.
Diagnosis: Time your hooks:
time ./scripts/hooks/post-write-validate.sh
Fix: Any PostToolUse hook should finish in < 3 seconds. If it takes longer:
- Move the validation to Stop
- Use
--incrementalor caching - Narrow the scope (just the modified file, not the whole project)
Pitfall 2: Hooks that are too strict
Symptom: Claude can't finish tasks because PreToolUse hooks block legitimate operations.
A problematic example:
# Blocks ANY file containing "TODO"
if grep -q "TODO" "$FILE"; then
echo "TODOs are not allowed in the code"
exit 1
fi
Fix: Blocking PreToolUse hooks should be reserved for genuine security risks, not style preferences. Use PostToolUse for warnings:
# PostToolUse: informs but doesn't block
TODO_COUNT=$(grep -c "TODO" "$FILE" 2>/dev/null || echo "0")
if [ "$TODO_COUNT" -gt 0 ]; then
echo "ℹ️ $TODO_COUNT TODOs found in $FILE"
fi
exit 0
Pitfall 3: Not testing the hooks
The mistake: Configuring hooks and assuming they work.
The fix: Test each hook by hand before putting it into production:
# Simulate the environment variables
CLAUDE_FILE_PATH="src/components/Test.tsx" \
CLAUDE_TOOL_NAME="Write" \
./scripts/hooks/post-write-validate.sh
Pitfall 4: Hooks that write files (a potential loop)
The mistake: A PostToolUse hook that modifies files (e.g. Prettier with --write).
The risk: If Claude Code treated Prettier's modification as a new write, the hook would fire again.
The reality: Claude Code has protections against this — changes made by hooks don't trigger new hooks. But it's still good practice to design idempotent hooks (running twice produces the same result as running once).
Pitfall 5: Ignoring the hooks' output
The mistake: Configuring hooks but never checking whether Claude is acting on their feedback.
The fix: Verify that Claude fixes the errors the hooks report. If Claude consistently ignores a hook's output, it could be that:
- The output is too verbose (Claude loses the errors in the noise)
- The format isn't clear (Claude doesn't understand what to fix)
- The hook reports too many false positives (Claude learns to ignore it)
Make the output concise and actionable.
Complete worked example
Scenario: you're setting up a full validation pipeline for a FastAPI + React project (monorepo).
Structure
scripts/hooks/
├── security-check.sh
├── safety-gate.sh
├── format.sh
├── lint.sh
├── related-tests.sh
├── type-check.sh
└── task-summary.sh
settings.json
{
"hooks": {
"PreToolUse": [
{ "matcher": "Write", "command": "./scripts/hooks/security-check.sh" },
{ "matcher": "Execute", "command": "./scripts/hooks/safety-gate.sh" }
],
"PostToolUse": [
{ "matcher": "Write", "command": "./scripts/hooks/format.sh" },
{ "matcher": "Write", "command": "./scripts/hooks/lint.sh" },
{ "matcher": "Write", "command": "./scripts/hooks/related-tests.sh" }
],
"Stop": [
{ "command": "./scripts/hooks/type-check.sh" },
{ "command": "./scripts/hooks/task-summary.sh" }
]
}
}
Multi-language script: format.sh
#!/bin/bash
FILE="$CLAUDE_FILE_PATH"
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx|*.css|*.scss|*.json|*.md)
npx prettier --write "$FILE" --log-level=warn 2>/dev/null || true
;;
*.py)
ruff format "$FILE" 2>/dev/null || true
;;
esac
exit 0
Multi-language script: lint.sh
#!/bin/bash
FILE="$CLAUDE_FILE_PATH"
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx)
ERRORS=$(npx eslint "$FILE" --quiet 2>&1)
if [ -n "$ERRORS" ]; then
echo "$ERRORS"
fi
;;
*.py)
ERRORS=$(ruff check "$FILE" 2>&1)
if [ -n "$ERRORS" ]; then
echo "$ERRORS"
fi
;;
esac
exit 0
task-summary.sh
#!/bin/bash
echo ""
echo "=== Task summary ==="
MODIFIED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
NEW=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')
echo "Modified files: $MODIFIED"
echo "New files: $NEW"
# Type check (TypeScript)
if [ -f "tsconfig.json" ]; then
TSC_ERRORS=$(npx tsc --noEmit 2>&1 | grep -c "error TS" || echo "0")
if [ "$TSC_ERRORS" -gt 0 ]; then
echo "TypeScript: ❌ $TSC_ERRORS errors"
else
echo "TypeScript: ✅ No errors"
fi
fi
# Tests
TEST_OUTPUT=$(npm test --silent 2>&1)
if [ $? -eq 0 ]; then
echo "Tests: ✅ Passing"
else
echo "Tests: ❌ Failing"
echo "$TEST_OUTPUT" | tail -5
fi
echo "========================"
Practice exercises
Exercise 1: Basic — Automatic lint
Configure a hook that runs your project's linter automatically after every write.
Requirements:
- Detects the file type (JS/TS/Python/CSS)
- Runs the right linter for each type
- Doesn't block (informational only)
- Clean output (errors only, no warnings)
Solution
Use the lint.sh script from the worked example. Swap the lint commands for the ones your project uses (ESLint, Ruff, Stylelint, etc.).
Configure in .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "./scripts/hooks/lint.sh"
}
]
}
}
Verify: ask Claude to create a file with a deliberate lint error and confirm the hook reports it.
Exercise 2: Basic — Format + Lint pipeline
Configure a 2-step pipeline: format first, then lint.
Requirements:
- Prettier (or your formatter) first
- The linter second
- Both only on code files (not configs, not markdown)
Solution
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "./scripts/hooks/format.sh"
},
{
"matcher": "Write",
"command": "./scripts/hooks/lint.sh"
}
]
}
}
Order matters: format before lint, so the linter doesn't report formatting problems that Prettier is about to fix.
Exercise 3: Intermediate — Security check
Create a PreToolUse hook that blocks writing files containing hardcoded secrets.
Requirements:
- Detect at least 5 patterns (password, api_key, secret, token, private_key)
- Block the write (exit 1)
- A clear error message with a suggestion for doing it right
- Don't block .env.example files (which legitimately contain placeholders)
Solution
Adapt the security-check.sh script from Pattern 4. Add the exception:
#!/bin/bash
FILE="$CLAUDE_FILE_PATH"
INPUT="$CLAUDE_TOOL_INPUT"
# Don't validate example files
if [[ "$FILE" == *.example ]] || [[ "$FILE" == *.sample ]]; then
exit 0
fi
PATTERNS=(
"password\s*=\s*['\"][^{]"
"api_key\s*=\s*['\"][^{]"
"secret\s*=\s*['\"][^{]"
"token\s*=\s*['\"][a-zA-Z0-9]"
"AWS_ACCESS_KEY_ID\s*=\s*['\"]AK"
"PRIVATE.KEY\s*=\s*['\"]"
)
for PATTERN in "${PATTERNS[@]}"; do
if echo "$INPUT" | grep -iEq "$PATTERN"; then
echo "⛔ Secret detected. Use environment variables."
echo " Example: process.env.API_KEY or os.environ['API_KEY']"
exit 1
fi
done
exit 0
Exercise 4: Intermediate — Related tests
Create a hook that automatically runs the tests related to the file Claude just wrote.
Requirements:
- Map source file → test file
- Run only the modified file's tests (not the whole suite)
- If there's no matching test, print an informational message
- If the file IS a test, run it directly
Solution
Use the run-related-tests.sh script from Pattern 3. Adapt the naming conventions to your project:
src/foo.ts→tests/foo.test.tsorsrc/__tests__/foo.test.tsapp/services/foo.py→tests/test_foo.py
Test it by hand with:
CLAUDE_FILE_PATH="src/components/Button.tsx" ./scripts/hooks/related-tests.sh
Exercise 5: Advanced — A complete pipeline across all 5 events
Configure all 5 real hook events with a professional validation pipeline.
Requirements:
- PreToolUse: security check (Write) + safety gate (Execute)
- PostToolUse: format + lint + related tests
- Stop: type check + a status summary
- Notification: an alert when long tasks complete
Solution
Use the full configuration from this capsule's worked example. Create each script, make it executable, and verify:
- Ask Claude to create a file → do format + lint run?
- Ask Claude to write a secret → does the security check block it?
- When Claude finishes a task → do you see the Stop hook's summary?
- Does the Notification hook run when Claude sends notifications?
Summary
What you learned in this capsule:
- Hooks create a feedback loop: Claude sees errors → Claude fixes them → hooks validate again
- 5 validation patterns: lint, format, tests, security, type checking
- Order matters: format → lint → tests (on PostToolUse), full suite (on Stop)
- Performance: PostToolUse hooks have to be fast (< 3s). Heavy validations belong in Stop
- Hooks vs CI/CD: complementary layers. Hooks = first line of defense (during development). CI/CD = second line (on push)
- Key pitfalls: slow hooks, overly strict hooks, not testing the hooks, ignoring the output
Next capsule: 05 - Permission system — how to control what Claude Code can and can't do in your project.
Additional resources
Official documentation
- Hooks — Complete reference for hooks and lifecycle events
- Settings — Configuring hooks across different scopes
- Best Practices — Recommendations for automation
Validation tools
- ESLint — Linter for JavaScript/TypeScript
- Prettier — Code formatter
- Ruff — Linter and formatter for Python (extremely fast)
Complementary
- Interactive Mode — How permissions interact with hooks
- GitHub Actions — CI/CD as a complement to hooks