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:
mkdir -p .claude/hooks .claude/statePrompt:
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:
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:
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:
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:
/hooksWhat 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.shblock-dangerous.shauto-format.shrun-related-tests.sh- a
Stopagent hook insettings.json
The starter hook configuration
Starter settings.json hook block
{
"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
| Hook | Supports | What it listens to | Consequence when it fires |
|---|---|---|---|
require-test-first.sh | RED -> GREEN | PreToolUse on `Write | Edit |
block-dangerous.sh | Safety around GREEN/SHIP | PreToolUse on Bash | Blocks destructive shell commands before they run |
auto-format.sh | GREEN | PostToolUse on successful edits | Rewrites the file immediately if a formatter applies |
run-related-tests.sh | GREEN -> REVIEW | PostToolUse on successful edits | Runs likely-related tests asynchronously and feeds back results |
Stop agent hook | SHIP | End of Claude completion | Blocks “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
#!/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 2The 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
#!/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 0The 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
#!/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 0The starter run-related-tests.sh
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
#!/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 0The 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.shblocks source edits unless the RED token exists - GREEN: allowed only after RED, then
auto-format.shandrun-related-tests.shshape the edit and its feedback - Review: assisted by fast feedback hooks, but not fully enforced
- Ship: enforced by the
Stopgate
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:
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:
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:
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:
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.