C9PG
Claude Code Docs

Your First Hook

This tutorial is intentionally small. The goal is to learn how hooks fit together without starting with a giant enforcement stack.

1. Create a harmless hook script

Run:

bash
mkdir -p .claude/hooks .claude/state

Prompt:

text
Create a tiny hook script at .claude/hooks/log-edit.sh that appends the edited file path to .claude/state/edited-files.log.
Keep it harmless and explain what it does.

What you should see: a tiny script whose only job is to leave a visible breadcrumb when Claude edits a file.

2. Add it as a PostToolUse hook

Prompt:

text
Update .claude/settings.json so Write, Edit, and MultiEdit trigger .claude/hooks/log-edit.sh after a successful edit.
Keep the change as small as possible.

What you should see: one narrow PostToolUse entry instead of a large hook matrix.

3. Make one small edit

Prompt:

text
Make one tiny docs edit so we can prove the hook runs.

What you should see: the edit completes normally and your hook script runs afterward.

4. Inspect the result

Prompt:

text
Show me the contents of .claude/state/edited-files.log and explain what just happened.

What you should see: the recently edited file path in the log.

5. Inspect the hook surface from inside Claude Code

Run:

text
/hooks

What you should see: confirmation that the hook exists and is part of the current configuration surface.

Advanced: Match the Starter Files

The starter project ships a real enforcement stack, not just a harmless logger.

It uses:

  • require-test-first.sh
  • block-dangerous.sh
  • auto-format.sh
  • run-related-tests.sh
  • a Stop agent hook in settings.json

The starter hook configuration

Starter settings.json hook block
json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/require-test-first.sh",
            "timeout": 5
          }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/block-dangerous.sh",
            "timeout": 5
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/auto-format.sh",
            "timeout": 10
          },
          {
            "type": "command",
            "command": "bash .claude/hooks/run-related-tests.sh",
            "async": true,
            "timeout": 180
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Verify that all required tests for this task pass. Run the project's full gate command. If verification is incomplete or failing, respond with decision: block and explain exactly what remains.",
            "timeout": 180
          }
        ]
      }
    ]
  }
}

Hook-by-hook consequences

HookSupportsWhat it listens toConsequence when it fires
require-test-first.shRED -> GREENPreToolUse on `WriteEdit
block-dangerous.shSafety around GREEN/SHIPPreToolUse on BashBlocks destructive shell commands before they run
auto-format.shGREENPostToolUse on successful editsRewrites the file immediately if a formatter applies
run-related-tests.shGREEN -> REVIEWPostToolUse on successful editsRuns likely-related tests asynchronously and feeds back results
Stop agent hookSHIPEnd of Claude completionBlocks “done” until the final repo gate is complete

The starter require-test-first.sh

This is the hook that makes RED explicit. It checks for .claude/state/test-forward.json, which the starter test-forward skill writes after a failing verification exists.

Consequence: if Claude tries to edit a real source file before RED exists, the edit is blocked and Claude is told to create and run a failing test first.

Starter require-test-first.sh
bash
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Allow writes to test files, docs, config, and non-source paths
if echo "$FILE_PATH" | grep -qE '(test|spec|__tests__|\.test\.|\.spec\.|\.md$|\.json$|\.yaml$|\.yml$|\.toml$|\.lock$|\.config\.)'; then
  exit 0
fi

# Allow writes under infrastructure directories
if echo "$FILE_PATH" | grep -qE '^(tests/|spec/|__tests__/|docs/|\.claude/|\.github/|\.vscode/)'; then
  exit 0
fi

# For source files: check if a RED token exists
STATE_FILE=".claude/state/test-forward.json"
if [ -f "$STATE_FILE" ]; then
  # Check freshness (10 minute TTL)
  if [ "$(uname)" = "Darwin" ]; then
    AGE=$(( $(date +%s) - $(stat -f%m "$STATE_FILE") ))
  else
    AGE=$(( $(date +%s) - $(stat -c%Y "$STATE_FILE") ))
  fi
  if [ "$AGE" -lt 600 ]; then
    exit 0
  fi
fi

echo "BLOCKED: No recent failing test found. Write and run a failing test first." >&2
echo "Run your test command and let it fail to create the RED token." >&2
exit 2

The starter block-dangerous.sh

This is the safety gate for destructive shell actions.

Consequence: the risky command never runs. Claude gets a block response instead of “trying it and hoping.”

Starter block-dangerous.sh
bash
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

# Block destructive patterns
if echo "$COMMAND" | grep -iE '\b(rm\s+-rf\s+/|DROP\s+TABLE|DELETE\s+FROM\s+\w+\s*;|TRUNCATE\s+TABLE|FORMAT\s+C)\b' > /dev/null; then
  echo "BLOCKED: Destructive command detected: $COMMAND" >&2
  exit 2  # Exit 2 = BLOCK. Not exit 1!
fi
exit 0

The starter auto-format.sh

This is the convenience hook in the stack.

Consequence: after a successful write, the file may be reformatted immediately. Claude sees the post-format file, not just its original edit.

Starter auto-format.sh
bash
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

case "$FILE_PATH" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.html|*.md)
    npx prettier --write "$FILE_PATH" 2>/dev/null || true ;;
  *.py)
    python3 -m black "$FILE_PATH" 2>/dev/null || true ;;
  *.go)
    gofmt -w "$FILE_PATH" 2>/dev/null || true ;;
  *.rs)
    rustfmt "$FILE_PATH" 2>/dev/null || true ;;
esac
exit 0

This is the fast-feedback hook.

Consequence: after a successful edit, Claude gets asynchronous test feedback without stopping the whole session while those tests run.

Starter run-related-tests.sh
bash
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Skip non-source files
if echo "$FILE_PATH" | grep -qE '\.(md|json|yaml|yml|toml|lock)$'; then
  exit 0
fi

# Try to find and run related test file
BASE=$(basename "$FILE_PATH" | sed 's/\.[^.]*$//')
TEST_FILE=$(find . -name "${BASE}.test.*" -o -name "${BASE}.spec.*" -o -name "test_${BASE}.*" 2>/dev/null | head -1)

if [ -n "$TEST_FILE" ]; then
  pnpm test -- "$TEST_FILE" 2>&1 || true
fi
exit 0

The starter Stop gate

This is the SHIP enforcement point.

Consequence: Claude cannot claim the task is done while the final repo gate is incomplete or failing. The session is blocked and told exactly what remains.

How the starter hooks enforce the Six Step Loop

  • Explore: not hook-enforced; this is team discipline
  • Plan: not hook-enforced; this is team discipline
  • RED: enforced indirectly because require-test-first.sh blocks source edits unless the RED token exists
  • GREEN: allowed only after RED, then auto-format.sh and run-related-tests.sh shape the edit and its feedback
  • Review: assisted by fast feedback hooks, but not fully enforced
  • Ship: enforced by the Stop gate

Hooks do not fully automate the loop. They enforce the parts that are concrete enough to verify mechanically.

What happens in a real session

Prompt:

text
Implement the validation change in src/validation.ts.

What you should see: if no RED token exists, require-test-first.sh blocks the edit and Claude is told to write and run a failing verification first.

Prompt:

text
Write the smallest failing test for the validation change and run it.

What you should see: the test fails, the test-forward skill records .claude/state/test-forward.json, and Claude can now move into implementation.

Prompt:

text
Now implement the validation change.

What you should see: the edit succeeds, auto-format.sh may rewrite the file, and run-related-tests.sh starts async feedback.

Prompt:

text
We are done. Finish the task.

What you should see: the Stop hook runs the final gate. If verification is incomplete or failing, Claude is blocked and told exactly what remains.

What this stack does not do

  • it does not force a good plan
  • it does not replace judgment
  • it does not automatically make review thorough
  • it can make sessions miserable if hooks are too slow or too broad

What this tutorial teaches

  • hooks are just event listeners plus handlers
  • the first hook should be easy to explain
  • it is better to learn the mechanics with a harmless example before moving into the real starter enforcement stack

Next step

When the hook mechanics feel comfortable, continue with Build the Full Project Setup and then Project Setup.