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.jsonwith 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
| Requirement | Minimum | Ideal |
|---|---|---|
src/ directory | With 3+ files | With separate modules |
| Python or TypeScript files | At least 5 | 10+ |
| Linter installed | ruff or eslint | Both |
| Git initialized | Yes | With 3+ commits |
| Python 3.8+ | Installed | With venv |
jq | Installed | — |
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
.pyfile → 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-*.mdfile 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:
- SessionStart created
changes.log:ls -la .claude/logs/changes.log - PostToolUse writes to the log: manually run a test of the hook
- The path in
session-summary.shmatches 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
- Claude Code Hooks (Anthropic Docs) — Official documentation of all the hook events
- Claude Code CLI Reference — The
-p,--output-format,--allowedToolsflags - Claude Code Settings — Hook configuration in settings.json
- jq Manual — Parsing JSON in bash scripts
- Python subprocess — subprocess reference for invoking Claude
- 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.