| name | hook-creator |
| description | Guide for designing and writing Claude Code hooks. MUST be invoked when creating a new hook, adding hooks to settings.json, debugging hook behavior, understanding what hook events are available and what JSON they receive, or implementing patterns like behavior correction, pre-stop checklists, context injection, side effects, and permission control.
|
Claude Code Hooks
Hooks are shell scripts (or prompt/agent handlers) that fire at lifecycle events
in a Claude Code session. They let you enforce behavior, inject context, play
sounds, run side effects, and more.
Fetch the upstream reference
before writing or modifying any hooks.
Do not rely on memory for event types, JSON schemas, exit code semantics,
stop_hook_active, async, permission decision format, $CLAUDE_ENV_FILE, or
settings.json structure: always fetch first.
When to Write a Hook
- Claude keeps making a specific mistake → PreToolUse behavior correction
- You want a checklist before Claude finishes → Stop with exit 2
- Claude doesn't know about project tooling → SessionStart context injection
- You want audio/visual feedback → Notification or Stop side effect
- You want to gate a destructive action → PreToolUse permission control
- You want to inject per-project env vars → SessionStart writing to
$CLAUDE_ENV_FILE
Where to Put Hooks
Global (Personal) Hooks
Hooks that apply across all projects (personal workflow preferences, reminders, sounds) belong in the global config and should travel with your dotfiles.
- Config:
~/.claude/settings.json (in this repo: dotfiles/claude/settings.json, synced by install.sh)
- Scripts:
dotfiles/bin/ (already on PATH, available everywhere, shared across machines); write in bash for maximum portability
Project-Local Hooks
Hooks specific to a project's workflow (enforcing project conventions, injecting repo-specific context) belong in the repo so the whole team gets them.
- Config:
.claude/settings.json at the project root (check this into version control)
- Scripts: reference via
$CLAUDE_PROJECT_DIR so the path works regardless of cwd:
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/my-hook.sh" }
Rule of thumb: if you'd want the hook on a fresh machine or in a new project, it's global. If it only makes sense inside one repo, it's project-local.
Hook Mechanics
Exit Codes: the Core API
| Exit | Meaning |
|---|
0 | Success. stdout parsed for JSON or used as plain-text context (SessionStart, UserPromptSubmit). |
2 | Blocking: prevents the action and feeds stderr back to Claude. |
| Other non-zero | Non-blocking error; first line of stderr shown in verbose mode only. |
Always send exit 2 messages to stderr via heredoc:
cat >&2 <<EOF
Something is still failing. Fix the issues before proceeding:
$DETAILS
EOF
exit 2
Write prose as long unbroken lines in the heredoc rather than hard-wrapping at 80 chars. The TUI reflows text at its own width; hard wraps baked into the source wrap awkwardly when the TUI width doesn't match.
Any text a hook feeds back to Claude, whether exit-2 stderr or a permissionDecisionReason (pattern 5), is read by Claude, not shown to the user. Address Claude in the second person and refer to the user in the third person: write "the user performs remote writes themselves; ask them to run it", not "you perform remote writes yourself".
stop_hook_active: Loop Prevention
Stop and SubagentStop events include "stop_hook_active": true/false. When
true, Claude is already continuing because a previous Stop hook blocked it.
Always check this field and exit 0 when it's true, or you'll create an
infinite loop.
INPUT=$(cat)
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
[[ "$STOP_HOOK_ACTIVE" == "true" ]] && exit 0
Hooks in a Group Run in Parallel
All hooks within a single hooks: [...] array fire at the same time; ordering
is not guaranteed. If one hook must run before another, combine them into a
single script.
Patterns
1. Behavior Correction (PreToolUse)
Block a tool call and tell Claude to do it differently. Exit 2 sends stderr to Claude; it then reconsiders.
Example: claude-uv-check, which blocks bare python in uv projects:
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
if ! is-uv-project; then
exit 0
fi
if echo "$COMMAND" | grep -q 'python' && ! echo "$COMMAND" | grep -q 'uv'; then
echo "Use 'uv run python' instead of 'python' directly." >&2
exit 2
fi
exit 0
Use matcher to scope to specific tools:
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "my-hook" }] }
2. Pre-Stop Work (Stop)
Do work before Claude stops (staging, linting) and optionally block with
feedback. Must guard against stop_hook_active.
Example: claude-stop-precommit, which stages tracked changes and runs the pre-commit loop:
INPUT=$(cat)
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
[[ "$STOP_HOOK_ACTIVE" == "true" ]] && exit 0
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
git add --update
[[ -f "$REPO_ROOT/.pre-commit-config.yaml" || -f "$REPO_ROOT/.pre-commit-config.yml" ]] || exit 0
if is-uv-project 2>/dev/null; then
RUNNER="uv run pre-commit"
elif command -v pre-commit &>/dev/null; then
RUNNER="pre-commit"
elif command -v uv &>/dev/null; then
RUNNER="uvx pre-commit"
else
exit 0
fi
$RUNNER run > /dev/null 2>&1 || true
git add --update
if OUTPUT=$($RUNNER run 2>&1); then
exit 0
fi
cat >&2 <<EOF
pre-commit is still failing after auto-fixing. Fix the issues before committing:
$OUTPUT
EOF
exit 2
Key preferences:
- Use
git add --update not git add -A: handle untracked files separately
with a dedicated hook
- Run pre-commit twice: first run auto-fixes, second run validates
3. Context Injection (SessionStart)
Print text to stdout (exit 0) and Claude sees it as context. Bail silently when not applicable.
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
[[ -f "$REPO_ROOT/some-config" ]] || exit 0
cat <<EOF
Context about this project's tooling...
EOF
4. Side Effects / Async (Stop, Notification)
For fire-and-forget work use "async": true:
{ "type": "command", "command": "my-hook", "async": true }
5. Permission Control (PreToolUse JSON output)
Return a JSON decision from stdout to allow/deny/ask:
echo '{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Use the project makefile instead."}}'
exit 0
Self-Testing Hooks
A hook with non-trivial matching (regex, path logic, command parsing) carries its
own tests, gated on the CLAUDE_HOOK_SELFTEST environment variable so nothing
pollutes its real arguments. Run them with CLAUDE_HOOK_SELFTEST=1 <hook-name>;
the hook prints results and exits 0 (pass) or 1 (fail). A safety hook (one that
blocks destructive actions) without a test is theater.
Keep the harness embedded in the hook, not factored into a shared script: a
hook should stay self-contained and copy-pasteable, and a small duplicated harness
is a better trade than a hidden dependency.
Declare participation with a # CLAUDE_HOOK_SELFTEST marker comment line. A runner
(claude-hook-selftests in this repo) discovers every hook carrying that marker,
runs each with the env var set, and fails if any do; wiring it into pre-commit
(files: ^bin/, pass_filenames: false) turns a hook regression into a failed
commit. The marker is a deliberate declaration, so discovery recovers the set from
the hooks themselves rather than from a hand-maintained list that drifts.
Bash hooks embed this block right after set -euo pipefail, before reading
stdin (so it never blocks waiting for input). The t helper re-invokes the hook
through its real stdin interface (with the env var unset, so it processes
normally) and asserts the exit code:
if [[ -n "${CLAUDE_HOOK_SELFTEST:-}" ]]; then
fails=0
t() {
local want="$1" cmd="$2" got
jq -cn --arg c "$cmd" '{tool_input: {command: $c}}' \
| CLAUDE_HOOK_SELFTEST= "$0" >/dev/null 2>&1 && got=0 || got=$?
[[ "$got" == "$want" ]] ||
{ printf 'FAIL want=%s got=%s :: %s\n' "$want" "$got" "$cmd"; fails=1; }
}
t 2 'command that should block'
t 0 'command that should pass'
if [[ "$fails" == 0 ]]; then
echo "$(basename "$0"): all self-tests passed"
fi
exit "$fails"
fi
Python hooks check the env var at the top of main() and run an embedded case
matrix against a pure (command, cwd, …) -> result function, so the tests need
no git or filesystem and stay deterministic:
def main() -> int:
if os.environ.get("CLAUDE_HOOK_SELFTEST"):
return _selftest()
...
Be adversarial when writing cases: try to defeat your future self. Cover flag and
spelling variants, wrapper/indirection forms, quoted-but-not-executed mentions,
and boundary conditions (e.g. a path exactly at a scope edge), not just the happy
path. Use distinct, non-default test values.
Hooks whose result depends on external state that a black-box self-test can't set
up deterministically (a hook that only fires inside a uv project, or against one
specific git repo) are not worth self-testing this way without a fixture; note
the omission rather than writing a test that passes only in one environment.
settings.json Structure
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "my-hook" }]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "my-stop-hook" },
{ "type": "command", "command": "my-sound-hook", "async": true }
]
}
]
}
}
Hook Script Boilerplate
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
exit 0
Hooks run in non-interactive subprocesses: shell functions sourced at startup
are not available. Any shared logic must be a standalone executable in bin/.