ASK KNOX
beta
LESSON 58

Hooks — Automating Claude Code Workflows

Hooks fire before and after every tool call. Use them to auto-lint, auto-test, send notifications, block dangerous commands, and build workflows that run themselves. Here is the complete playbook.

10 min read·Advanced Claude Code

Every tool call Claude makes — every Edit, every Bash, every Write — passes through two event gates: one before it executes and one after. Hooks are your scripts at those gates. You can run any shell command, inspect context, block execution, enforce quality standards, send notifications, or fire off side effects. No extra tool required. The plumbing is already there.

This is how you build a Claude Code environment that enforces its own quality standards without you having to ask.

The Event Model

The core hook events:

PreToolUse — fires before a tool executes. If your script exits with code 2, the tool call is blocked. If it exits with 0, execution continues. Use this for validation: is Claude about to run a dangerous bash command? Is it about to edit a protected file?

PostToolUse — fires after a tool executes successfully. The tool's input and result arrive in the JSON payload on stdin. Use this for side effects: run the linter after every file edit, run the test suite after a file that matches *.test.* is written, send a webhook after a deployment completes.

Notification — fires when Claude sends a user-facing notification (asking for permission, reporting completion, etc.). Use this to trigger external alerts — a Discord message, a macOS notification, a Slack ping.

Stop — fires when Claude finishes responding at the end of a turn — not when the session ends. Use it to trigger follow-up automation once Claude's response completes.

SessionEnd — fires when the Claude Code session terminates. Use this to clean up temp files, commit a session log, send a summary.

Additional events exist for finer-grained control: UserPromptSubmit (before Claude processes your prompt), SessionStart, SubagentStop (when a subagent finishes), and PreCompact (before context compaction).

Configuration

Hooks live in .claude/settings.json at the project root, or in ~/.claude/settings.json for global hooks.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "npm run lint --silent 2>&1 | head -20"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/validate-bash.sh"
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/session-summary.sh"
          }
        ]
      }
    ]
  }
}

The matcher field is a regex matched against the tool name. Edit|Write matches any Edit or Write call. Bash matches only bash. An empty string "" matches everything.

Practical Hook Patterns

Auto-Lint on Every Edit

The most common hook. Catch lint errors immediately, before they accumulate:

#!/bin/bash
# ~/.claude/hooks/auto-lint.sh
# Runs after every Edit or Write
INPUT=$(cat)  # hook context arrives as JSON on stdin
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[ -z "$FILE" ] && exit 0
cd "$CLAUDE_PROJECT_DIR" 2>/dev/null || exit 0
npx eslint --fix --quiet "$FILE" 2>&1 | head -10
exit 0  # Never block — just report

Auto-Test Trigger

Run the test suite whenever a test file is written:

#!/bin/bash
# Triggered by PostToolUse matcher: "Edit|Write"
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE" == *".test."* ]]; then
  cd "$CLAUDE_PROJECT_DIR"
  npm test -- --run "$FILE" 2>&1 | tail -20
fi
exit 0

Blocking Dangerous Bash Commands

#!/bin/bash
# PreToolUse matcher: "Bash"
# Block rm -rf on anything outside /tmp
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE 'rm -rf' && ! echo "$CMD" | grep -qE 'rm -rf /tmp(/|[[:space:]]|$)'; then
  echo "BLOCKED: rm -rf outside /tmp requires explicit confirmation" >&2
  exit 2  # Exit 2 blocks the tool call
fi
exit 0

Discord Notification on Session End

#!/bin/bash
# SessionEnd hook — sends session summary to Discord
WEBHOOK_URL="$DISCORD_WEBHOOK_URL"
MESSAGE="Claude Code session ended. Project: $(basename $PWD)"
curl -s -X POST "$WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d "{\"content\": \"$MESSAGE\"}" > /dev/null
exit 0

Hooks are the enforcement layer — the people equivalent for automated systems. They make the rules real.

The Hook Input Contract: JSON on stdin

Claude Code does not pass tool context through environment variables — hooks receive a JSON payload on stdin. The key fields:

FieldContent
tool_nameThe tool being invoked (Edit, Bash, Write, etc.)
tool_input.file_pathFile path for file tools
tool_input.commandCommand string for Bash
session_idUnique session identifier
hook_event_nameWhich event fired (PreToolUse, PostToolUse, ...)

The one piece of context that does arrive as an environment variable is CLAUDE_PROJECT_DIR — the absolute path to the project root. Read the payload once and parse it with jq:

INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

Use these fields to write hooks that respond to the specific context — not every edit, but edits to specific paths; not every bash call, but ones that match dangerous patterns.

Security Considerations

Hooks run as shell commands with your full user permissions. A few rules that are not optional:

Never put secrets in hook scripts that are committed. Your Discord webhook URL, database credentials, API keys — these go in environment variables that the hook reads from your shell, not hardcoded in the script source.

Validate hook script inputs. If the command string from tool_input.command is used in a hook, treat it as untrusted input. Do not eval it or pass it to shell expansion without sanitizing.

Test hooks with exit 0 first. Write the hook to do nothing but print what it would do. Confirm the logic is correct before connecting it to real side effects.

Writing Hook Scripts

A few production-grade conventions:

#!/opt/homebrew/bin/bash
# Always use the explicit Homebrew bash path on macOS for launchd compatibility
set -euo pipefail   # Fail fast on any error

INPUT=$(cat)        # read the stdin JSON payload once
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // "none"')

# Log to a file for debugging — never to stdout unless you want it in Claude's context
LOG="$HOME/.claude/hooks/hook-$(date +%Y%m%d).log"
echo "[$(date -u +%H:%M:%S)] Tool: $TOOL Path: $FILE" >> "$LOG"

# Do your work here
# ...

exit 0

Store hook scripts in ~/.claude/hooks/ and make them executable with chmod +x. Reference them by absolute path in your settings configuration.

Lesson Drill

Implement the auto-lint hook. Create .claude/settings.json with the PostToolUse config pointing to a script that runs your project's linter after every Edit or Write. Run a Claude session, make a change, and observe the hook output appearing in Claude's context. Then add one more hook — a SessionEnd hook that writes a one-line session log entry to ~/.claude/session-log.txt.

Two hooks running and you will feel the difference within a day of use.

Bottom Line

Hooks turn Claude Code from a smart assistant into an enforcing system. PreToolUse validates and blocks. PostToolUse triggers quality side effects. Notification routes alerts. Stop fires when Claude finishes responding; SessionEnd handles cleanup. Exit code 2 blocks. Exit code 0 continues. Keep hook scripts tight, log to files not stdout, never commit secrets, and test with passive scripts before enabling blocking logic. The stdin JSON payload gives you enough context to build any enforcement rule you need.