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 CodeEffect
0OK — continues normally
1Error reported to Claude — Claude sees the script output and can decide to fix
2Not 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

AspectPreToolUsePostToolUse
TimingBefore executionAfter execution
Can blockYes (exit 2)No (already ran)
Exit 1Claude decides whether to continueClaude receives the error and can fix
Use caseValidate, preventReact, fix, log
PerformanceMust be fastCan be heavier
MatcherTool nameTool name
CycleDoesn't create cyclesCan create correction cycles
RevertPrevents the actionCan'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:

  1. Only activates for files in src/
  2. Looks for a corresponding test file in tests/
  3. If it exists, runs it with pytest
  4. If the test fails, reports to Claude (exit 1)
  5. 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:

  1. PostToolUse for Edit/Write: auto-format + lint (two chained commands)
  2. SubagentStop: generates a report per subagent
  3. Stop: generates a session summary only if more than 5 tools were used
  4. 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

  1. Claude Code Hooks (Anthropic Docs) — Official documentation of PostToolUse, SubagentStart/Stop, Stop
  2. Claude Code Settings — Hook configuration in settings.json
  3. Ruff — Python Linter — Fast linter for Python, ideal for PostToolUse hooks
  4. ESLint — JavaScript/TypeScript linter for hooks
  5. Prettier — Formatter for auto-format hooks
  6. Claude Code Sub-agents — Subagent reference to understand the SubagentStart/Stop events
  7. jq Manual — Parsing JSON in hook scripts
  8. 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.