Module 6: Advanced Hooks and Headless SDK

6. Project — Automated Workflow with Hooks + SDK End-to-End

6. Project — Automated Workflow with Hooks + SDK End-to-End

Project Description

You've learned each piece of the puzzle separately: SessionStart for automatic setup, PreToolUse for validation, PostToolUse for post-execution reaction, SubagentStop for subagent tracking, and the headless SDK for programmatic execution from Python and TypeScript. Now you integrate everything into a functional pipeline.

In this project you build a complete automated workflow: a Python script that starts Claude Code in headless mode, asks it to implement a feature, and hooks react to each event during the execution. SessionStart verifies the environment, PreToolUse blocks dangerous operations, PostToolUse auto-lints each edit, SubagentStop generates logs per subagent, and Stop produces a final session report. The Python script processes the result and generates an executive summary.

The flow is: you run python scripts/automated-workflow.py "Implement feature X" and the system takes care of the rest. The script invokes Claude Code, the hooks react to each event, and at the end you receive a report with the modified files, the cost, and the result.

This project closes Module 6 and Phase 2 of the guide. If the pipeline runs from start to finish with hooks reacting at each point — you've mastered Claude Code automation. If in addition you can explain how each hook contributes to the pipeline and how the SDK orchestrates everything — you've internalized the mental model of hooks + SDK as an integrated system.


Project Objective

Build an automated workflow that integrates 4 hooks (SessionStart, PreToolUse, PostToolUse, SubagentStop) with a Python SDK script, run it on a real project, and analyze the complete pipeline.

By the end of this project:

  • ✅ You'll have 4 hook scripts in scripts/hooks/ that react to lifecycle events
  • ✅ A settings.json with all the hook configuration
  • ✅ A Python SDK script that orchestrates the complete execution
  • ✅ Each file edit will be auto-linted by PostToolUse
  • ✅ Each Bash command will be validated by PreToolUse
  • ✅ Session reports generated automatically by Stop
  • ✅ An end-to-end pipeline that works without human intervention

Estimated duration: 1-1.5 hours (setup: 15 min + hooks: 20 min + SDK script: 20 min + execution: 15 min + iteration: 15 min).


Technical Specifications

Technology Stack

  • Tool: Claude Code (recent version with hook support)
  • Hook scripts: Bash
  • SDK script: Python 3.8+
  • Dependencies: jq (for JSON parsing in bash)
  • Base project: Any project with source code in src/

Base Project Requirements

RequirementMinimumIdeal
src/ directoryWith 3+ filesWith separate modules
Python or TypeScript filesAt least 510+
Linter installedruff or eslintBoth
Git initializedYesWith 3+ commits
Python 3.8+InstalledWith venv
jqInstalled—

Requirements verification

python3 --version
jq --version
claude --version
git status
ls src/

Final Project Structure

By the end, your project will have these additional files:

your-project/
├── .claude/
│   ├── settings.json              ← Hook configuration
│   ├── logs/                      ← Logs generated by hooks
│   │   ├── changes.log
│   │   └── subagents.log
│   └── reports/                   ← Session reports
│       └── session-*.md
├── scripts/
│   ├── hooks/
│   │   ├── session-setup.sh       ← SessionStart hook
│   │   ├── validate-commands.sh   ← PreToolUse hook
│   │   ├── auto-lint.sh           ← PostToolUse hook
│   │   ├── subagent-report.sh     ← SubagentStop hook
│   │   └── session-summary.sh     ← Stop hook
│   └── automated-workflow.py      ← Orchestrating SDK script
└── src/                           ← Your source code

Step 1: Create the Directory Structure

mkdir -p scripts/hooks
mkdir -p .claude/logs
mkdir -p .claude/reports

Step 2: SessionStart Hook — Environment Setup

This hook runs once at the start of each session. It verifies that the environment is ready.

Create scripts/hooks/session-setup.sh:

#!/bin/bash

echo "=== SESSION SETUP ==="

LOG_DIR=".claude/logs"
REPORT_DIR=".claude/reports"
mkdir -p "$LOG_DIR" "$REPORT_DIR"

if [ -f "$LOG_DIR/changes.log" ]; then
    BACKUP="$LOG_DIR/changes-$(date +%Y%m%d-%H%M%S).log.bak"
    mv "$LOG_DIR/changes.log" "$BACKUP"
fi

touch "$LOG_DIR/changes.log"
touch "$LOG_DIR/subagents.log"

if ! git rev-parse --git-dir > /dev/null 2>&1; then
    echo "ERROR: Not a git repository"
    exit 1
fi

BRANCH=$(git branch --show-current 2>/dev/null)
echo "Branch: $BRANCH"

DIRTY=$(git status --porcelain | wc -l | tr -d ' ')
if [ "$DIRTY" -gt 20 ]; then
    echo "WARNING: $DIRTY uncommitted files"
    echo "Consider committing before continuing"
    exit 1
fi

if [ -f "requirements.txt" ] && [ -d ".venv" ]; then
    source .venv/bin/activate 2>/dev/null
fi

if [ -f "package.json" ]; then
    if [ ! -d "node_modules" ]; then
        echo "Installing npm dependencies..."
        npm ci --silent 2>/dev/null
    fi
fi

if command -v ruff &> /dev/null; then
    echo "Linter: ruff $(ruff --version 2>/dev/null)"
elif command -v npx &> /dev/null; then
    echo "Linter: eslint (via npx)"
else
    echo "WARNING: No linter found (ruff or eslint)"
fi

echo "Session ID: $(date +%Y%m%d-%H%M%S)"
echo "=== SETUP COMPLETE ==="
exit 0

Make it executable:

chmod +x scripts/hooks/session-setup.sh

Step 3: PreToolUse Hook — Command Validation

This hook runs before each invocation of the Bash tool. It blocks dangerous commands and warns about sensitive operations.

Create scripts/hooks/validate-commands.sh:

#!/bin/bash

INPUT=$(cat -)

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if [ "$TOOL_NAME" != "Bash" ] || [ -z "$COMMAND" ]; then
    exit 0
fi

BLOCKED_PATTERNS=(
    "rm -rf /"
    "rm -rf ~"
    "rm -rf \."
    "DROP DATABASE"
    "DROP TABLE"
    "TRUNCATE TABLE"
    "mkfs"
    "dd if="
    ":(){:|:&};:"
    "chmod -R 777 /"
    "npm publish"
    "git push --force"
    "git reset --hard"
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
    if echo "$COMMAND" | grep -qi "$pattern"; then
        echo "BLOCKED: Dangerous command detected"
        echo "Pattern: $pattern"
        echo "Command: $COMMAND"
        TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
        echo "$TIMESTAMP | BLOCKED | $pattern | $COMMAND" >> .claude/logs/changes.log
        exit 2
    fi
done

WARNING_PATTERNS=(
    "sudo"
    "chmod 777"
    "curl.*|.*sh"
    "wget.*|.*bash"
    "pip install"
    "npm install.*-g"
)

for pattern in "${WARNING_PATTERNS[@]}"; do
    if echo "$COMMAND" | grep -qi "$pattern"; then
        echo "WARNING: Potentially risky command"
        echo "Pattern: $pattern"
        echo "Command: $COMMAND"
        exit 1
    fi
done

TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
echo "$TIMESTAMP | ALLOWED | Bash | $COMMAND" >> .claude/logs/changes.log

exit 0

Make it executable:

chmod +x scripts/hooks/validate-commands.sh

Step 4: PostToolUse Hook — Auto-Lint After Edits

This hook runs after each Edit or Write. It lints the modified file and reports errors to Claude.

Create scripts/hooks/auto-lint.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

TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
echo "$TIMESTAMP | EDITED | $TOOL_NAME | $FILE" >> .claude/logs/changes.log

EXTENSION="${FILE##*.}"

case "$EXTENSION" in
    py)
        if command -v ruff &> /dev/null; then
            LINT_RESULT=$(ruff check "$FILE" 2>&1)
            LINT_EXIT=$?
            if [ $LINT_EXIT -ne 0 ]; then
                echo "LINT ERROR in $FILE:"
                echo "$LINT_RESULT"
                echo "$TIMESTAMP | LINT_FAIL | $FILE" >> .claude/logs/changes.log
                exit 1
            fi
            echo "$TIMESTAMP | LINT_PASS | $FILE" >> .claude/logs/changes.log
        fi
        ;;
    ts|tsx)
        if command -v npx &> /dev/null; then
            LINT_RESULT=$(npx eslint "$FILE" --no-warn-ignored 2>&1)
            LINT_EXIT=$?
            if [ $LINT_EXIT -ne 0 ]; then
                echo "LINT ERROR in $FILE:"
                echo "$LINT_RESULT"
                echo "$TIMESTAMP | LINT_FAIL | $FILE" >> .claude/logs/changes.log
                exit 1
            fi
            echo "$TIMESTAMP | LINT_PASS | $FILE" >> .claude/logs/changes.log
        fi
        ;;
    js|jsx)
        if command -v npx &> /dev/null; then
            LINT_RESULT=$(npx eslint "$FILE" --no-warn-ignored 2>&1)
            LINT_EXIT=$?
            if [ $LINT_EXIT -ne 0 ]; then
                echo "LINT ERROR in $FILE:"
                echo "$LINT_RESULT"
                echo "$TIMESTAMP | LINT_FAIL | $FILE" >> .claude/logs/changes.log
                exit 1
            fi
            echo "$TIMESTAMP | LINT_PASS | $FILE" >> .claude/logs/changes.log
        fi
        ;;
    *)
        ;;
esac

exit 0

Make it executable:

chmod +x scripts/hooks/auto-lint.sh

Step 5: SubagentStop Hook — Report per Subagent

This hook runs when a subagent finishes its execution. It generates a log with the changed files.

Create scripts/hooks/subagent-report.sh:

#!/bin/bash

INPUT=$(cat -)

AGENT_NAME=$(echo "$INPUT" | jq -r '.agent_name // "unknown-agent"')
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")

LOG_FILE=".claude/logs/subagents.log"

CHANGED_FILES=$(git diff --name-only 2>/dev/null | head -20)
CHANGED_COUNT=$(echo "$CHANGED_FILES" | grep -c . 2>/dev/null || echo "0")

cat >> "$LOG_FILE" << EOF
--- Subagent Report ---
Agent: $AGENT_NAME
Timestamp: $TIMESTAMP
Files changed: $CHANGED_COUNT
$CHANGED_FILES
-----------------------
EOF

echo "Subagent $AGENT_NAME completed. $CHANGED_COUNT files changed."
exit 0

Make it executable:

chmod +x scripts/hooks/subagent-report.sh

Step 6: Stop Hook — Session Summary

This hook runs when Claude finishes responding. It generates a Markdown report of the session.

Create scripts/hooks/session-summary.sh:

#!/bin/bash

TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
DATE_STR=$(date +"%Y%m%d-%H%M%S")

REPORT_DIR=".claude/reports"
LOG_FILE=".claude/logs/changes.log"
AGENT_LOG=".claude/logs/subagents.log"

CHANGED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')

if [ "$CHANGED" -eq 0 ] && [ ! -s "$LOG_FILE" ]; then
    exit 0
fi

REPORT_FILE="$REPORT_DIR/session-$DATE_STR.md"

TOOL_COUNT=0
LINT_FAILS=0
BLOCKED_COUNT=0

if [ -f "$LOG_FILE" ]; then
    TOOL_COUNT=$(wc -l < "$LOG_FILE" | tr -d ' ')
    LINT_FAILS=$(grep -c "LINT_FAIL" "$LOG_FILE" 2>/dev/null || echo "0")
    BLOCKED_COUNT=$(grep -c "BLOCKED" "$LOG_FILE" 2>/dev/null || echo "0")
fi

cat > "$REPORT_FILE" << EOF
# Session Report
**Date:** $TIMESTAMP

## Summary
- Tool invocations logged: $TOOL_COUNT
- Files with git changes: $CHANGED
- Lint failures caught: $LINT_FAILS
- Commands blocked: $BLOCKED_COUNT

## Files Changed
$(git diff --name-only 2>/dev/null || echo "None")

## Git Diff Stats
$(git diff --stat 2>/dev/null || echo "No changes")

## Tool Usage Log
$(if [ -f "$LOG_FILE" ]; then cat "$LOG_FILE"; else echo "No log"; fi)

## Subagent Activity
$(if [ -f "$AGENT_LOG" ] && [ -s "$AGENT_LOG" ]; then cat "$AGENT_LOG"; else echo "No subagent activity"; fi)
EOF

echo "Session report: $REPORT_FILE"
exit 0

Make it executable:

chmod +x scripts/hooks/session-summary.sh

Step 7: Configure settings.json

Create .claude/settings.json with all the hooks connected:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/hooks/session-setup.sh"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/hooks/validate-commands.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/hooks/auto-lint.sh"
          }
        ]
      }
    ],
    "SubagentStop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/hooks/subagent-report.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/hooks/session-summary.sh"
          }
        ]
      }
    ]
  }
}

Step 8: Python SDK Script — The Orchestrator

This is the script that connects everything. It runs Claude Code in headless mode, lets the hooks react to each event, and processes the final result.

Create scripts/automated-workflow.py:

#!/usr/bin/env python3
"""
Automated Workflow: Hooks + SDK Pipeline
Runs Claude Code in headless mode with active hooks.
The hooks react to each lifecycle event.

Usage:
    python scripts/automated-workflow.py "Task description"
    python scripts/automated-workflow.py --file tasks/feature-request.md
"""

import subprocess
import json
import sys
import os
from datetime import datetime
from pathlib import Path

PROJECT_ROOT = Path(__file__).parent.parent
LOGS_DIR = PROJECT_ROOT / ".claude" / "logs"
REPORTS_DIR = PROJECT_ROOT / ".claude" / "reports"


def ensure_dirs():
    LOGS_DIR.mkdir(parents=True, exist_ok=True)
    REPORTS_DIR.mkdir(parents=True, exist_ok=True)


def run_claude(prompt, tools, timeout=600):
    cmd = [
        "claude", "-p", prompt,
        "--output-format", "json",
        "--allowedTools", ",".join(tools),
    ]

    print(f"\n{'='*60}")
    print(f"EXECUTING: Claude Code (headless)")
    print(f"Tools: {', '.join(tools)}")
    print(f"Timeout: {timeout}s")
    print(f"{'='*60}\n")

    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
            cwd=str(PROJECT_ROOT),
        )
    except subprocess.TimeoutExpired:
        return {
            "is_error": True,
            "error_type": "timeout",
            "result": f"Execution timed out after {timeout}s",
        }
    except FileNotFoundError:
        return {
            "is_error": True,
            "error_type": "not_found",
            "result": "claude CLI not found. Install: npm install -g @anthropic-ai/claude-code",
        }

    if result.returncode != 0:
        return {
            "is_error": True,
            "error_type": "exit_code",
            "result": result.stderr or "Unknown error",
            "exit_code": result.returncode,
        }

    try:
        parsed = json.loads(result.stdout)
    except json.JSONDecodeError:
        return {
            "is_error": True,
            "error_type": "json_parse",
            "result": f"Invalid JSON output: {result.stdout[:200]}",
        }

    return parsed


def read_task_file(filepath):
    with open(filepath, "r") as f:
        return f.read().strip()


def collect_hook_logs():
    logs = {}

    changes_log = LOGS_DIR / "changes.log"
    if changes_log.exists():
        content = changes_log.read_text().strip()
        if content:
            lines = content.split("\n")
            logs["tool_invocations"] = len(lines)
            logs["lint_failures"] = sum(1 for l in lines if "LINT_FAIL" in l)
            logs["blocked_commands"] = sum(1 for l in lines if "BLOCKED" in l)
            logs["edited_files"] = sum(1 for l in lines if "EDITED" in l)
            logs["changes_detail"] = lines[-20:]

    agents_log = LOGS_DIR / "subagents.log"
    if agents_log.exists():
        content = agents_log.read_text().strip()
        if content:
            logs["subagent_activity"] = content

    return logs


def find_latest_report():
    if not REPORTS_DIR.exists():
        return None

    reports = sorted(REPORTS_DIR.glob("session-*.md"), reverse=True)
    if reports:
        return reports[0]
    return None


def generate_summary(task, claude_result, hook_logs, report_path):
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    summary = []
    summary.append(f"{'='*60}")
    summary.append(f"WORKFLOW EXECUTION SUMMARY")
    summary.append(f"{'='*60}")
    summary.append(f"")
    summary.append(f"Timestamp: {timestamp}")
    summary.append(f"Task: {task[:100]}...")
    summary.append(f"")

    if claude_result.get("is_error"):
        summary.append(f"Status: FAILED")
        summary.append(f"Error: {claude_result.get('result', 'Unknown')}")
    else:
        summary.append(f"Status: SUCCESS")
        summary.append(f"Cost: ${claude_result.get('cost_usd', 0):.4f}")
        summary.append(f"Duration: {claude_result.get('duration_ms', 0)}ms")
        summary.append(f"Turns: {claude_result.get('num_turns', 0)}")

    summary.append(f"")
    summary.append(f"--- Hook Activity ---")
    summary.append(f"Tool invocations: {hook_logs.get('tool_invocations', 0)}")
    summary.append(f"Files edited: {hook_logs.get('edited_files', 0)}")
    summary.append(f"Lint failures caught: {hook_logs.get('lint_failures', 0)}")
    summary.append(f"Commands blocked: {hook_logs.get('blocked_commands', 0)}")

    if hook_logs.get("subagent_activity"):
        summary.append(f"")
        summary.append(f"--- Subagent Activity ---")
        summary.append(hook_logs["subagent_activity"])

    if report_path:
        summary.append(f"")
        summary.append(f"Session report: {report_path}")

    if not claude_result.get("is_error"):
        summary.append(f"")
        summary.append(f"--- Claude Output (first 500 chars) ---")
        summary.append(claude_result.get("result", "")[:500])

    summary.append(f"")
    summary.append(f"{'='*60}")

    return "\n".join(summary)


def main():
    if len(sys.argv) < 2:
        print("Usage:")
        print('  python scripts/automated-workflow.py "Task description"')
        print("  python scripts/automated-workflow.py --file tasks/feature.md")
        sys.exit(1)

    if sys.argv[1] == "--file":
        if len(sys.argv) < 3:
            print("Error: --file requires a path to the file")
            sys.exit(1)
        task = read_task_file(sys.argv[2])
    else:
        task = " ".join(sys.argv[1:])

    ensure_dirs()

    print(f"\n{'#'*60}")
    print(f"# AUTOMATED WORKFLOW — Hooks + SDK Pipeline")
    print(f"{'#'*60}")
    print(f"\nTask: {task[:100]}...")
    print(f"Project: {PROJECT_ROOT}")
    print(f"Hooks: SessionStart, PreToolUse, PostToolUse, SubagentStop, Stop")

    tools = ["Read", "Write", "Edit", "Grep", "Glob", "Bash"]

    result = run_claude(task, tools, timeout=600)

    hook_logs = collect_hook_logs()

    report_path = find_latest_report()

    summary = generate_summary(task, result, hook_logs, report_path)
    print(summary)

    summary_path = REPORTS_DIR / f"workflow-{datetime.now().strftime('%Y%m%d-%H%M%S')}.txt"
    with open(summary_path, "w") as f:
        f.write(summary)

    if result.get("is_error"):
        print(f"\nFull error: {result.get('result', '')}")
        sys.exit(1)

    print(f"\nWorkflow summary saved: {summary_path}")
    sys.exit(0)


if __name__ == "__main__":
    main()

Make it executable:

chmod +x scripts/automated-workflow.py

Step 9: Verify the Configuration

Before running the complete pipeline, verify each piece:

Verify the hook scripts

echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' | ./scripts/hooks/validate-commands.sh
echo "Exit code: $?"

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | ./scripts/hooks/validate-commands.sh
echo "Exit code: $?"

echo '{"tool_name":"Edit","tool_input":{"file_path":"src/test.py"}}' | ./scripts/hooks/auto-lint.sh
echo "Exit code: $?"

Expected result:

  • ls -la → exit 0 (allowed)
  • rm -rf / → exit 2 (blocked)
  • Nonexistent file → exit 0 (no file to lint)

Verify the structure

ls -la scripts/hooks/
ls -la .claude/settings.json
cat .claude/settings.json | jq .

Verify that the Claude CLI works in headless

claude -p "Say 'hello'" --output-format json --allowedTools "Read" | jq .result

Step 10: Run the Complete Pipeline

Basic execution

python scripts/automated-workflow.py "Add type hints to all the functions in src/api/ that don't have them"

Execution with a task file

Create tasks/add-validation.md:

Add input validation to all the endpoints in src/api/:

1. Read each routes/endpoints file
2. Identify functions that receive parameters without validation
3. Add Pydantic models for request validation
4. Make sure each endpoint returns 422 errors for invalid input

Only modify files in src/api/. Don't touch tests.
python scripts/automated-workflow.py --file tasks/add-validation.md

What you should observe

1. SessionStart runs:

=== SESSION SETUP ===
Branch: feature/hooks-demo
Linter: ruff 0.8.x
Session ID: 20260313-143022
=== SETUP COMPLETE ===

2. PreToolUse validates each Bash command:

  • Safe commands (ls, cat, python -m pytest) → exit 0, allowed
  • Dangerous commands (if Claude were to try any) → exit 2, blocked

3. PostToolUse lints each edit:

  • If Claude edits a .py file → ruff check runs
  • If the lint fails → Claude receives the error and fixes
  • If the lint passes → exit 0, continues

4. SubagentStop records activity (if Claude uses subagents):

  • Each subagent that finishes generates an entry in the log

5. Stop generates the session report:

  • A session-*.md file appears in .claude/reports/

6. The Python script generates the final summary:

============================================================
WORKFLOW EXECUTION SUMMARY
============================================================

Timestamp: 2026-03-13 14:35:42
Task: Add type hints to all the functions in src/api/...

Status: SUCCESS
Cost: $0.0156
Duration: 45200ms
Turns: 12

--- Hook Activity ---
Tool invocations: 28
Files edited: 6
Lint failures caught: 2
Commands blocked: 0

Step 11: Analyze the Execution

Review the logs

cat .claude/logs/changes.log

You should see a chronological history of all the actions:

2026-03-13 14:30:22 | ALLOWED | Bash | ls src/api/
2026-03-13 14:30:45 | EDITED | Edit | src/api/routes.py
2026-03-13 14:30:46 | LINT_PASS | src/api/routes.py
2026-03-13 14:31:02 | EDITED | Edit | src/api/schemas.py
2026-03-13 14:31:03 | LINT_FAIL | src/api/schemas.py
2026-03-13 14:31:15 | EDITED | Edit | src/api/schemas.py
2026-03-13 14:31:16 | LINT_PASS | src/api/schemas.py

In this example, Claude edited schemas.py, the lint failed, Claude fixed it, and the lint passed on the second attempt.

Review the session report

ls .claude/reports/session-*.md
cat .claude/reports/session-*.md

Review the workflow summary

cat .claude/reports/workflow-*.txt

Success checklist

✅ SessionStart ran at the start
✅ PreToolUse validated at least 1 Bash command
✅ PostToolUse linted at least 1 edited file
✅ At least 1 lint failure was fixed by Claude
✅ Stop generated a session report
✅ The Python script generated a summary with cost and metrics
✅ changes.log has the complete history
✅ The pipeline worked from start to finish without manual intervention

Step 12: Iteration — Extend the Pipeline

Extension 1: Add auto-test after lint

Modify scripts/hooks/auto-lint.sh so that, after passing the lint, it also runs related tests:

# Add at the end of auto-lint.sh, after the successful lint for Python:
if [ "$LINT_EXIT" -eq 0 ]; then
    BASENAME=$(basename "$FILE" .py)
    TEST_FILE="tests/test_${BASENAME}.py"
    if [ -f "$TEST_FILE" ]; then
        TEST_RESULT=$(python -m pytest "$TEST_FILE" -x --tb=line -q 2>&1)
        if [ $? -ne 0 ]; then
            echo "TEST FAILURE after editing $FILE:"
            echo "$TEST_RESULT" | tail -10
            echo "$TIMESTAMP | TEST_FAIL | $FILE" >> .claude/logs/changes.log
            exit 1
        fi
        echo "$TIMESTAMP | TEST_PASS | $FILE" >> .claude/logs/changes.log
    fi
fi

Extension 2: Notification on completion

Add at the end of scripts/hooks/session-summary.sh:

if command -v osascript &> /dev/null; then
    osascript -e "display notification \"$CHANGED files changed, $LINT_FAILS lint issues caught\" with title \"Claude Workflow Complete\""
fi

Extension 3: Multiple tasks in sequence

Modify the Python script to run multiple tasks:

tasks = [
    ("Add type hints to src/api/", ["Read", "Write", "Edit", "Grep", "Glob"]),
    ("Run the tests and report", ["Read", "Grep", "Glob", "Bash"]),
    ("Generate documentation for src/api/", ["Read", "Grep", "Glob"]),
]

total_cost = 0
for task, tools in tasks:
    result = run_claude(task, tools)
    if not result.get("is_error"):
        total_cost += result.get("cost_usd", 0)
        print(f"Task done: ${result.get('cost_usd', 0):.4f}")

print(f"\nTotal pipeline cost: ${total_cost:.4f}")

Common Errors and Solutions

Error 1: "Permission denied: ./scripts/hooks/session-setup.sh"

Symptom: The hook fails immediately with a permission error.

Cause: The scripts don't have execution permissions.

Solution:

chmod +x scripts/hooks/*.sh

Error 2: "jq: command not found"

Symptom: The hooks that parse JSON fail silently. PreToolUse and PostToolUse don't work correctly.

Cause: jq isn't installed.

Solution:

# macOS
brew install jq

# Ubuntu/Debian
sudo apt-get install jq

# Verify
jq --version

Error 3: "The auto-lint creates an infinite loop"

Symptom: Claude edits a file, the lint fails, Claude fixes it, the lint fails again, in an endless cycle.

Cause: The linter detects an error Claude doesn't know how to fix, or the auto-format changes something the linter rejects.

Solution: Add an attempt counter in auto-lint.sh:

ATTEMPT_FILE="/tmp/lint-attempt-$(echo "$FILE" | md5sum | cut -c1-8)"
ATTEMPTS=0
if [ -f "$ATTEMPT_FILE" ]; then
    ATTEMPTS=$(cat "$ATTEMPT_FILE")
fi
if [ "$ATTEMPTS" -ge 3 ]; then
    echo "WARNING: Skipping lint after 3 failures for $FILE"
    rm -f "$ATTEMPT_FILE"
    exit 0
fi
echo $((ATTEMPTS + 1)) > "$ATTEMPT_FILE"

Error 4: "The session report is empty"

Symptom: The Stop hook generates a .md file but with no useful content.

Cause: The change logs weren't created because SessionStart didn't run or because the PostToolUse hooks didn't log.

Solution: Verify that:

  1. SessionStart created changes.log: ls -la .claude/logs/changes.log
  2. PostToolUse writes to the log: manually run a test of the hook
  3. The path in session-summary.sh matches the current directory

Error 5: "Claude CLI not found in the Python script"

Symptom: The Python script reports FileNotFoundError or claude: command not found.

Cause: claude isn't in the PATH when Python runs subprocess.

Solution: Use the full path:

import shutil

claude_path = shutil.which("claude")
if not claude_path:
    print("Error: claude not found in PATH")
    sys.exit(1)

cmd = [claude_path, "-p", prompt, ...]

Error 6: "Hooks don't run in headless mode"

Symptom: The SDK script works but no hook fires.

Cause: The hooks need settings.json to be in the correct directory and for Claude Code to recognize them.

Solution: Verify that .claude/settings.json exists at the project root and that you're running Claude from that directory. The cwd in subprocess must point to the project:

result = subprocess.run(cmd, cwd=str(PROJECT_ROOT), ...)

Error 7: "The script takes too long"

Symptom: The pipeline takes more than 10 minutes for simple tasks.

Cause: The PostToolUse hooks (lint, test) add overhead on each tool. If Claude makes 50 edits, that's 50 linter invocations.

Solution: Limit the lint to files that actually changed:

LAST_LINT_HASH=$(md5sum "$FILE" 2>/dev/null | cut -d' ' -f1)
HASH_FILE="/tmp/lint-hash-$(echo "$FILE" | md5sum | cut -c1-8)"
if [ -f "$HASH_FILE" ] && [ "$(cat "$HASH_FILE")" = "$LAST_LINT_HASH" ]; then
    exit 0
fi
echo "$LAST_LINT_HASH" > "$HASH_FILE"

Error 8: "The subagent logs are empty"

Symptom: There's no activity in subagents.log even though Claude used subagents.

Cause: SubagentStop may not be supported in your version of Claude Code, or Claude didn't use subagents for the task (it worked directly).

Solution: Verify whether Claude actually delegated to subagents. For simple tasks, Claude works directly without creating subagents. The subagent log only fills up if Claude explicitly delegates to a subagent defined in .claude/agents/.


Connection to the Next Module

You've built a complete automated pipeline. The hooks detect every event in Claude Code's lifecycle, and the SDK lets you start it all from a script. But there's a detail: the Python script has to run on your machine. You're there, running python scripts/automated-workflow.py. You no longer write the prompts manually, but you're still present.

Module 7: Remote Control and CLAUDE.md for Teams eliminates that last dependency. Remote control lets you run and monitor Claude Code from any device — your phone, another computer, a server. And CLAUDE.md for teams establishes the shared rules that make the automation consistent across all team members.

The SDK you learned here is the foundation of remote control: if you can invoke Claude Code from a local Python script, you can invoke Claude Code from a remote server. The hooks you configured are the rules shared via CLAUDE.md. The local automation you built becomes distributed automation.


Summary

  • You built an end-to-end automated pipeline with 5 hooks + 1 Python SDK script
  • SessionStart configures the environment automatically — dependencies, logs, git checks
  • PreToolUse validates each Bash command against dangerous patterns — exit 2 blocks, exit 1 warns
  • PostToolUse auto-lints each edited file — lint errors go to Claude for self-correction
  • SubagentStop generates logs of each subagent's lifecycle
  • Stop produces a session report with metrics: edited files, lint failures, blocked commands
  • The Python SDK script orchestrates everything: it invokes Claude in headless mode, collects hook logs, generates an executive summary
  • The pipeline works without human intervention — you run a command and receive a report at the end
  • The hooks and the SDK complement each other: hooks control from the inside, the SDK controls from the outside
  • This pipeline is the foundation of remote control (Module 7) and the capstone project (Module 8)

Project Resources

  1. Claude Code Hooks (Anthropic Docs) — Official documentation of all the hook events
  2. Claude Code CLI Reference — The -p, --output-format, --allowedTools flags
  3. Claude Code Settings — Hook configuration in settings.json
  4. jq Manual — Parsing JSON in bash scripts
  5. Python subprocess — subprocess reference for invoking Claude
  6. Claude Code Best Practices — Automation and hook best practices

Next module: Module 7 (Remote Control and CLAUDE.md for Teams) extends what you built here. The headless SDK you use locally becomes a service you can control from any device. The hooks you configured are packaged in CLAUDE.md as team standards. The automation goes from personal to organizational — your pipeline works the same for the whole team, from anywhere.