Module 6: Advanced Hooks and Headless SDK
3. PostToolUse, Subagent Events, and Stop — React, Track, and Close
3. PostToolUse, Subagent Events, and Stop — React, Track, and Close
Description
In the previous capsule you learned the prevention hooks: SessionStart configures the environment before you work, PreToolUse validates before a tool runs. Now comes the other half: the reaction hooks. PostToolUse triggers after a tool finishes — it's where you put auto-lint, auto-test, change logging. SubagentStart and SubagentStop track the subagent lifecycle: when they start, when they finish, what they produced. Stop triggers when Claude finishes responding — the place for final cleanup and report generation. PermissionRequest intercepts permission requests to create custom approval flows.
The conceptual difference is clear: PreToolUse asks "should this run?" PostToolUse asks "what do I do now that it ran?" SubagentStop asks "what did this agent produce?" Stop asks "what do I do when Claude finishes?" Together they form the complete event lifecycle inside Claude Code.
By the end of this capsule you'll have hooks that auto-format your code after each edit, generate logs of the subagent lifecycle, produce reports at the end of sessions, and manage permissions automatically. Your Claude Code doesn't just prevent problems — it reacts intelligently to everything that happens.
PostToolUse: Reaction After Execution
When it triggers
PostToolUse runs immediately after a tool completes its execution. The matcher works the same as in PreToolUse — it filters by tool name.
Flow:
Claude decides to use Edit → PreToolUse hook → Edit runs → PostToolUse hook
(validates) (modifies file) (reacts)
The PostToolUse JSON input
PostToolUse receives the same JSON format as PreToolUse, but with additional information about the result:
{
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": "src/api/routes.py",
"old_string": "def get_users():",
"new_string": "def get_users(skip: int = 0, limit: int = 100):"
},
"session_id": "abc123",
"transcript_path": "/tmp/claude/transcript-abc123.json"
}
Exit codes in PostToolUse
| Exit Code | Effect |
|---|---|
0 | OK — continues normally |
1 | Error reported to Claude — Claude sees the script output and can decide to fix |
2 | Not applicable (the tool already ran) — treated as an error |
The key difference from PreToolUse: exit code 2 in PostToolUse can't revert what the tool already did. The tool already ran. Exit 1 is the useful code here — it reports a problem to Claude so it can decide how to react.
Pattern 1: Auto-lint after edits
PostToolUse's most valuable use case. Every time Claude edits a file, the linter runs automatically:
./scripts/auto-lint.sh:
#!/bin/bash
INPUT=$(cat -)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
if [ -z "$FILE" ]; then
exit 0
fi
if [ ! -f "$FILE" ]; then
exit 0
fi
EXTENSION="${FILE##*.}"
case "$EXTENSION" in
py)
RESULT=$(ruff check "$FILE" 2>&1)
if [ $? -ne 0 ]; then
echo "LINT ERROR in $FILE:"
echo "$RESULT"
exit 1
fi
;;
ts|tsx|js|jsx)
RESULT=$(npx eslint "$FILE" --no-warn-ignored 2>&1)
if [ $? -ne 0 ]; then
echo "LINT ERROR in $FILE:"
echo "$RESULT"
exit 1
fi
;;
*)
exit 0
;;
esac
exit 0
Configuration:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "./scripts/auto-lint.sh"
}
]
}
]
}
}
When the lint fails (exit 1), Claude receives the linter's output and can decide to fix automatically. This creates a self-correction cycle:
Claude edits a file
→ PostToolUse: lint fails → output goes to Claude
→ Claude edits to fix
→ PostToolUse: lint passes → exit 0
→ Claude continues
Pattern 2: Auto-format after edits
Similar to auto-lint, but it formats the code automatically instead of just reporting:
./scripts/auto-format.sh:
#!/bin/bash
INPUT=$(cat -)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
exit 0
fi
EXTENSION="${FILE##*.}"
case "$EXTENSION" in
py)
ruff format "$FILE" --quiet 2>/dev/null
;;
ts|tsx|js|jsx)
npx prettier --write "$FILE" --log-level error 2>/dev/null
;;
json)
npx prettier --write "$FILE" --log-level error 2>/dev/null
;;
esac
exit 0
With auto-format, the hook modifies the file directly. It doesn't need to report to Claude because the correction is automatic. Exit 0 always.
Pattern 3: Auto-test after edits in specific files
./scripts/auto-test.sh:
#!/bin/bash
INPUT=$(cat -)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
if [ -z "$FILE" ]; then
exit 0
fi
if echo "$FILE" | grep -q "^src/api/"; then
TEST_FILE="tests/test_$(basename "$FILE")"
if [ -f "$TEST_FILE" ]; then
RESULT=$(python -m pytest "$TEST_FILE" -x --tb=short 2>&1)
if [ $? -ne 0 ]; then
echo "TEST FAILURE after editing $FILE:"
echo "$RESULT" | tail -20
exit 1
fi
fi
fi
exit 0
If you edit a file in src/api/, the hook looks for its corresponding test file and runs it. If it fails, Claude receives the test errors.
Pattern 4: Change logging
./scripts/log-changes.sh:
#!/bin/bash
INPUT=$(cat -)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
LOG_DIR=".claude/logs"
mkdir -p "$LOG_DIR"
if [ -n "$FILE" ]; then
echo "$TIMESTAMP | $TOOL | $FILE" >> "$LOG_DIR/changes.log"
fi
exit 0
This hook creates a log of every file Claude touches during the session. Useful for auditing and debugging.
SubagentStart and SubagentStop: Subagent Lifecycle
SubagentStart: When a subagent starts
It triggers when Claude launches a subagent. The matcher filters by the agent type's name.
{
"hooks": {
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": "./scripts/subagent-start.sh"
}
]
}
]
}
}
./scripts/subagent-start.sh:
#!/bin/bash
INPUT=$(cat -)
AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_name // .tool_input.agent_name // "unknown"')
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
LOG_DIR=".claude/logs"
mkdir -p "$LOG_DIR"
echo "$TIMESTAMP | START | $AGENT_TYPE" >> "$LOG_DIR/subagents.log"
exit 0
SubagentStop: When a subagent finishes
The most valuable hook for tracking. It triggers when a subagent completes its execution. You can generate reports, metrics, or trigger actions based on what the subagent produced.
./scripts/subagent-stop.sh:
#!/bin/bash
INPUT=$(cat -)
AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_name // .tool_input.agent_name // "unknown"')
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
LOG_DIR=".claude/logs"
mkdir -p "$LOG_DIR"
echo "$TIMESTAMP | STOP | $AGENT_TYPE" >> "$LOG_DIR/subagents.log"
exit 0
Matcher in SubagentStart/SubagentStop
The matcher filters by the agent type's name:
{
"hooks": {
"SubagentStop": [
{
"matcher": "backend-agent",
"hooks": [
{
"type": "command",
"command": "./scripts/backend-report.sh"
}
]
},
{
"matcher": "frontend-agent",
"hooks": [
{
"type": "command",
"command": "./scripts/frontend-report.sh"
}
]
}
]
}
}
Pattern: Automatic report per subagent
./scripts/subagent-report.sh:
#!/bin/bash
INPUT=$(cat -)
AGENT=$(echo "$INPUT" | jq -r '.agent_name // "unknown"')
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
REPORT_DIR=".claude/reports"
mkdir -p "$REPORT_DIR"
REPORT_FILE="$REPORT_DIR/subagent-${AGENT}-$(date +%Y%m%d-%H%M%S).md"
CHANGED_FILES=$(git diff --name-only 2>/dev/null)
cat > "$REPORT_FILE" << EOF
# Subagent Report: $AGENT
**Timestamp:** $TIMESTAMP
## Files Changed
$CHANGED_FILES
## Git Diff Summary
$(git diff --stat 2>/dev/null)
EOF
echo "Report generated: $REPORT_FILE"
exit 0
Stop: Cleanup at the End of the Response
When it triggers
Stop runs when Claude finishes responding in its current turn. Not when the session closes — when Claude completes a response.
Use case: Session report
./scripts/session-summary.sh:
#!/bin/bash
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
REPORT_DIR=".claude/reports"
mkdir -p "$REPORT_DIR"
CHANGED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
ADDED=$(git diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
LOG_FILE=".claude/logs/changes.log"
REPORT_FILE="$REPORT_DIR/session-$(date +%Y%m%d-%H%M%S).md"
cat > "$REPORT_FILE" << EOF
# Session Summary
**Timestamp:** $TIMESTAMP
## Changes
- Files modified: $CHANGED
- Files staged: $ADDED
## Tool Usage
$(if [ -f "$LOG_FILE" ]; then cat "$LOG_FILE"; else echo "No log available"; fi)
## Git Status
$(git status --short 2>/dev/null)
EOF
exit 0
Configuration:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "./scripts/session-summary.sh"
}
]
}
]
}
}
Stop has no matcher
Like SessionStart, Stop doesn't filter by tool or by agent. It triggers every time Claude finishes responding. If the Stop hook logic is heavy, consider adding internal conditions:
#!/bin/bash
CHANGED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
if [ "$CHANGED" -eq 0 ]; then
exit 0
fi
# Only generate a report if there were changes
./scripts/generate-report.sh
PermissionRequest: Automatic Approval
When it triggers
PermissionRequest triggers when Claude needs a permission that isn't pre-approved. Normally, a question would appear in your terminal: "Can I run this command?" With a hook, you can auto-approve or auto-reject based on rules.
Use case: Auto-approve safe operations
./scripts/auto-approve.sh:
#!/bin/bash
INPUT=$(cat -)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
case "$TOOL" in
"Read"|"Glob"|"Grep")
exit 0
;;
"Bash")
if echo "$COMMAND" | grep -qE "^(ls|cat|head|tail|wc|grep|find|echo|pwd|date)"; then
exit 0
fi
echo "Requires manual approval: $COMMAND"
exit 1
;;
*)
exit 1
;;
esac
Configuration:
{
"hooks": {
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "./scripts/auto-approve.sh"
}
]
}
]
}
}
Exit 0 in PermissionRequest auto-approves the permission. Exit 1 lets the normal flow continue (it asks you). Exit 2 rejects the permission automatically.
Complete Configuration: All the Reactive Hooks
A settings.json with all the reaction hooks configured:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "./scripts/auto-format.sh"
},
{
"type": "command",
"command": "./scripts/auto-lint.sh"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "./scripts/log-changes.sh"
}
]
}
],
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": "./scripts/subagent-start.sh"
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "./scripts/subagent-report.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "./scripts/session-summary.sh"
}
]
}
]
}
}
With this configuration, each Claude action generates automatic reactions:
- Edits a file → it's formatted and linted automatically
- Any tool → its use is logged
- A subagent starts → the start is recorded
- A subagent finishes → a report is generated
- Claude finishes its response → a session summary is generated
The Complete Lifecycle
All the hooks in order
SessionStart ──→ Setup (once at the start)
│
▼
PreToolUse ───→ Validate (before each tool)
│
▼
[Tool runs]
│
▼
PostToolUse ──→ React (after each tool)
│
▼
SubagentStart → Log start (when a subagent starts)
│
▼
[Subagent works]
│
▼
SubagentStop ─→ Report (when a subagent finishes)
│
▼
Stop ─────────→ Cleanup (when Claude finishes responding)
│
▼
PermissionRequest → Approve/Reject (when a permission is needed)
Interaction between hooks
The hooks don't step on each other, but they can create cycles:
Claude edits a file
→ PostToolUse: auto-lint fails (exit 1)
→ Claude receives the error, edits to fix
→ PostToolUse: auto-lint runs again
→ If it passes: exit 0, continues
→ If it fails again: exit 1, Claude tries another fix
This cycle is desirable (self-correction), but it can loop if the problem has no obvious solution. Claude generally gives up after 2-3 failed attempts. If you need an explicit limit, add it in the script:
#!/bin/bash
ATTEMPT_FILE="/tmp/lint-attempts-$$"
ATTEMPTS=0
if [ -f "$ATTEMPT_FILE" ]; then
ATTEMPTS=$(cat "$ATTEMPT_FILE")
fi
if [ "$ATTEMPTS" -ge 3 ]; then
echo "WARNING: 3+ lint failures. Continuing without fixing."
rm -f "$ATTEMPT_FILE"
exit 0
fi
echo $((ATTEMPTS + 1)) > "$ATTEMPT_FILE"
# ... lint logic ...
Comparison: PreToolUse vs PostToolUse
| Aspect | PreToolUse | PostToolUse |
|---|---|---|
| Timing | Before execution | After execution |
| Can block | Yes (exit 2) | No (already ran) |
| Exit 1 | Claude decides whether to continue | Claude receives the error and can fix |
| Use case | Validate, prevent | React, fix, log |
| Performance | Must be fast | Can be heavier |
| Matcher | Tool name | Tool name |
| Cycle | Doesn't create cycles | Can create correction cycles |
| Revert | Prevents the action | Can't revert |
Practical rule: If you can detect the problem beforehand → PreToolUse. If you need to see the result to react → PostToolUse.
Troubleshooting
"The PostToolUse auto-lint doesn't report errors to Claude"
Cause: The script always returns exit 0, even when the lint fails.
Solution: Make sure the script returns exit 1 when the lint detects errors. The script's output (stdout) is sent to Claude as context:
RESULT=$(ruff check "$FILE" 2>&1)
if [ $? -ne 0 ]; then
echo "$RESULT"
exit 1
fi
"SubagentStop doesn't trigger"
Cause: The matcher doesn't match the agent name, or subagent hooks aren't supported in your version.
Solution: Verify the exact agent name. Use a hook without a matcher to capture all subagents and confirm the event fires:
{
"hooks": {
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "echo 'SubagentStop fired' >> /tmp/hook-debug.log"
}
]
}
]
}
}
"The Stop hook generates reports on every response (too many)"
Cause: Stop triggers on every Claude turn, not just at the end of the session.
Solution: Add conditional logic to only generate meaningful reports:
#!/bin/bash
CHANGED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
if [ "$CHANGED" -eq 0 ]; then
exit 0
fi
# Only generate a report if there were real changes
"PostToolUse auto-format modifies files and confuses Claude"
Cause: The auto-format changes the file content after Claude edited it. Claude may notice the discrepancy.
Solution: Auto-format is generally transparent, but if it causes problems, use auto-lint (which reports without modifying) instead of auto-format. Alternatively, configure the formatter to be consistent with the conventions Claude already follows.
"The PermissionRequest hook doesn't auto-approve"
Cause: The hook returns exit 1 (normal flow) instead of exit 0 (auto-approve).
Solution: Verify that the script returns exit 0 for the cases you want to auto-approve. Test manually:
echo '{"tool_name": "Read"}' | ./scripts/auto-approve.sh
echo $?
Exercises
Exercise 1: Auto-lint for Python (Easy)
Create a PostToolUse hook that runs ruff check on Python files after each Edit or Write. If the lint fails, report the error to Claude (exit 1).
See solution
./scripts/lint-python.sh:
#!/bin/bash
INPUT=$(cat -)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
exit 0
fi
if [[ "$FILE" != *.py ]]; then
exit 0
fi
RESULT=$(ruff check "$FILE" 2>&1)
if [ $? -ne 0 ]; then
echo "Lint errors in $FILE:"
echo "$RESULT"
exit 1
fi
exit 0
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "./scripts/lint-python.sh" }
]
}
]
}
}
Exercise 2: Subagent log with duration (Easy)
Create SubagentStart and SubagentStop hooks that log the start and end of each subagent in a .claude/logs/agents.log file, including timestamps that let you calculate the duration.
See solution
./scripts/agent-log-start.sh:
#!/bin/bash
INPUT=$(cat -)
AGENT=$(echo "$INPUT" | jq -r '.agent_name // "unknown"')
echo "$(date +%s) | START | $AGENT | $(date +"%H:%M:%S")" >> .claude/logs/agents.log
exit 0
./scripts/agent-log-stop.sh:
#!/bin/bash
INPUT=$(cat -)
AGENT=$(echo "$INPUT" | jq -r '.agent_name // "unknown"')
echo "$(date +%s) | STOP | $AGENT | $(date +"%H:%M:%S")" >> .claude/logs/agents.log
exit 0
{
"hooks": {
"SubagentStart": [
{ "hooks": [{ "type": "command", "command": "./scripts/agent-log-start.sh" }] }
],
"SubagentStop": [
{ "hooks": [{ "type": "command", "command": "./scripts/agent-log-stop.sh" }] }
]
}
}
The Unix timestamp at the start lets you calculate the duration by subtracting START from STOP for the same agent.
Exercise 3: Stop hook with conditional report (Medium)
Create a Stop hook that generates a Markdown report only if Claude modified more than 3 files during the session. The report should include: the list of files, git diff stats, and the time.
See solution
./scripts/conditional-report.sh:
#!/bin/bash
CHANGED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
if [ "$CHANGED" -le 3 ]; then
exit 0
fi
REPORT_DIR=".claude/reports"
mkdir -p "$REPORT_DIR"
REPORT="$REPORT_DIR/session-$(date +%Y%m%d-%H%M%S).md"
cat > "$REPORT" << EOF
# Session Report — $(date +"%Y-%m-%d %H:%M:%S")
## Files Changed ($CHANGED)
$(git diff --name-only 2>/dev/null)
## Diff Stats
$(git diff --stat 2>/dev/null)
EOF
echo "Report: $REPORT"
exit 0
Exercise 4: PostToolUse with selective auto-test (Medium)
Create a PostToolUse hook that:
- Only activates for files in
src/ - Looks for a corresponding test file in
tests/ - If it exists, runs it with pytest
- If the test fails, reports to Claude (exit 1)
- If there's no test file, does nothing (exit 0)
See solution
./scripts/auto-test-selective.sh:
#!/bin/bash
INPUT=$(cat -)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
exit 0
fi
if ! echo "$FILE" | grep -q "^src/"; then
exit 0
fi
if [[ "$FILE" != *.py ]]; then
exit 0
fi
BASENAME=$(basename "$FILE")
MODULE=$(echo "$FILE" | sed 's|^src/||' | sed 's|/|_|g' | sed 's|\.py$||')
TEST_CANDIDATES=(
"tests/test_${BASENAME}"
"tests/test_${MODULE}.py"
)
for TEST_FILE in "${TEST_CANDIDATES[@]}"; do
if [ -f "$TEST_FILE" ]; then
RESULT=$(python -m pytest "$TEST_FILE" -x --tb=short 2>&1)
if [ $? -ne 0 ]; then
echo "Test failure after editing $FILE:"
echo "$RESULT" | tail -15
exit 1
fi
exit 0
fi
done
exit 0
Exercise 5: Complete combination of reactive hooks (Hard)
Design a settings.json with:
- PostToolUse for Edit/Write: auto-format + lint (two chained commands)
- SubagentStop: generates a report per subagent
- Stop: generates a session summary only if more than 5 tools were used
- PermissionRequest: auto-approves Read/Glob/Grep, rejects Drop/Delete
Write the JSON configuration and the scripts.
See solution
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "./scripts/hooks/format.sh" },
{ "type": "command", "command": "./scripts/hooks/lint.sh" }
]
}
],
"SubagentStop": [
{
"hooks": [
{ "type": "command", "command": "./scripts/hooks/agent-report.sh" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "./scripts/hooks/session-report.sh" }
]
}
],
"PermissionRequest": [
{
"hooks": [
{ "type": "command", "command": "./scripts/hooks/auto-perms.sh" }
]
}
]
}
}
./scripts/hooks/format.sh:
#!/bin/bash
INPUT=$(cat -)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
[ -z "$FILE" ] || [ ! -f "$FILE" ] && exit 0
case "${FILE##*.}" in
py) ruff format "$FILE" --quiet 2>/dev/null ;;
ts|tsx|js|jsx) npx prettier --write "$FILE" --log-level error 2>/dev/null ;;
esac
exit 0
./scripts/hooks/lint.sh:
#!/bin/bash
INPUT=$(cat -)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
[ -z "$FILE" ] || [ ! -f "$FILE" ] && exit 0
case "${FILE##*.}" in
py) RESULT=$(ruff check "$FILE" 2>&1); [ $? -ne 0 ] && echo "$RESULT" && exit 1 ;;
ts|tsx|js|jsx) RESULT=$(npx eslint "$FILE" 2>&1); [ $? -ne 0 ] && echo "$RESULT" && exit 1 ;;
esac
exit 0
./scripts/hooks/agent-report.sh:
#!/bin/bash
INPUT=$(cat -)
AGENT=$(echo "$INPUT" | jq -r '.agent_name // "unknown"')
mkdir -p .claude/reports
echo "## $AGENT — $(date +%H:%M:%S)" >> .claude/reports/agents.md
echo "Files: $(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')" >> .claude/reports/agents.md
echo "" >> .claude/reports/agents.md
exit 0
./scripts/hooks/session-report.sh:
#!/bin/bash
LOG=".claude/logs/changes.log"
[ ! -f "$LOG" ] && exit 0
COUNT=$(wc -l < "$LOG" | tr -d ' ')
[ "$COUNT" -le 5 ] && exit 0
mkdir -p .claude/reports
cat > ".claude/reports/session-$(date +%Y%m%d-%H%M%S).md" << EOF
# Session: $(date)
Tools used: $COUNT
$(cat "$LOG")
EOF
exit 0
./scripts/hooks/auto-perms.sh:
#!/bin/bash
INPUT=$(cat -)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
case "$TOOL" in
Read|Glob|Grep) exit 0 ;;
esac
if echo "$CMD" | grep -qiE "(DROP|DELETE|TRUNCATE)"; then
echo "BLOCKED: destructive operation"
exit 2
fi
exit 1
Summary
- PostToolUse triggers after each tool execution — ideal for auto-lint, auto-format, auto-test, and logging
- Exit 1 in PostToolUse reports errors to Claude, which can decide to fix automatically — creating a self-correction cycle
- SubagentStart and SubagentStop track the subagent lifecycle — useful for logging, metrics, and per-agent reports
- Stop triggers when Claude finishes responding — the place for session reports and cleanup
- PermissionRequest lets you auto-approve (exit 0) or auto-reject (exit 2) permission requests
- PostToolUse can create correction cycles (edit → lint fails → Claude fixes → lint again) — generally desirable, but add limits if necessary
- The complete lifecycle is: SessionStart → PreToolUse → [execution] → PostToolUse → SubagentStart → [subagent] → SubagentStop → Stop
- All hooks receive JSON via stdin and communicate decisions via exit codes (0, 1, 2)
Additional Resources
- Claude Code Hooks (Anthropic Docs) — Official documentation of PostToolUse, SubagentStart/Stop, Stop
- Claude Code Settings — Hook configuration in settings.json
- Ruff — Python Linter — Fast linter for Python, ideal for PostToolUse hooks
- ESLint — JavaScript/TypeScript linter for hooks
- Prettier — Formatter for auto-format hooks
- Claude Code Sub-agents — Subagent reference to understand the SubagentStart/Stop events
- jq Manual — Parsing JSON in hook scripts
- Claude Code Best Practices — Automation best practices
Next capsule: In capsule 04 you cross from the world of hooks to the world of the SDK. You'll learn to run Claude Code from Python scripts — not as an interactive tool, but as a service that receives prompts and returns JSON results. Automatic changelog scripts, programmatic code review, and test fixing without touching the terminal. Claude Code becomes a Python function you can call from any pipeline.