A curated library of deterministic hooks — type-check gates, dangerous-command blocks, secret scans, and more. Each is a reusable pattern with a runnable script, the settings.json to register it, and a verified real-world source.
Hooks are the enforcement layer of the harness. For where they sit relative to skills and connectors, read Skills vs MCP vs Hooks and the Harness Engineering pillar.
01 — Lifecycle
The Hook Events You'll Use Most
Claude Code exposes many lifecycle events; a handful cover almost every practical hook. Each recipe below is tagged with the event it fires on.
Each card carries a complete, runnable recipe with a copy button, the requirements to run it, and at least one real repository where the pattern appears. Deep-link any pattern with its anchor, e.g. /hooks#typecheck-gate.
The agent edits TypeScript freely and only discovers type errors at build time — often several turns later, after the broken code has been built on.
Recipe · bash
#!/usr/bin/env bash
# PostToolUse hook on Edit|Write. Runs tsc against the whole project
# and surfaces the errors immediately after an edit lands.
set -euo pipefail
input=$(cat)
file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
# Only react to TypeScript edits.
case "$file" in
*.ts|*.tsx) ;;
*) exit 0 ;;
esac
# Walk up to the nearest tsconfig.json so the check runs in the right project.
dir=$(dirname "$file")
while [ "$dir" != "/" ] && [ ! -f "$dir/tsconfig.json" ]; do
dir=$(dirname "$dir")
done
[ -f "$dir/tsconfig.json" ] || exit 0
if ! out=$(cd "$dir" && npx tsc --noEmit 2>&1); then
echo "tsc reported type errors after editing $file:" >&2
printf '%s\n' "$out" | grep -F "$(basename "$file")" | head -20 >&2
exit 2 # exit 2 = blocking: the agent must address the errors
fi
exit 0
The agent announces it is done while the test suite is red. Without a gate, "finished" is self-graded and the regression ships.
Recipe · bash
#!/usr/bin/env bash
# Stop hook. Runs the test suite when the turn ends and blocks the
# stop with a JSON decision if anything fails, forcing the agent to keep going.
set -uo pipefail
# Detect the toolchain from canonical markers; skip silently if unknown.
if [ -f package.json ]; then
cmd="npm test --silent"
elif [ -f pyproject.toml ] || [ -f pytest.ini ]; then
cmd="pytest -q"
elif [ -f go.mod ]; then
cmd="go test ./..."
else
exit 0
fi
if ! out=$($cmd 2>&1); then
reason=$(printf '%s' "$out" | tail -20 | jq -Rs .)
printf '{"decision":"block","reason":%s}\n' "$reason"
exit 0
fi
exit 0
Lint violations accumulate silently between edits and only surface in CI. Running the linter on each edited file keeps the tree green as the agent works.
Recipe · bash
#!/usr/bin/env bash
# PostToolUse hook on Edit|Write. Runs ESLint with --fix on the single
# edited file. Non-blocking: warns on remaining problems but lets the turn proceed.
set -euo pipefail
input=$(cat)
file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
[ -n "$file" ] && [ -f "$file" ] || exit 0
case "$file" in
*.js|*.jsx|*.ts|*.tsx|*.vue) ;;
*) exit 0 ;;
esac
bin="npx --no-install eslint"
[ -x node_modules/.bin/eslint ] && bin="node_modules/.bin/eslint"
if ! $bin --fix "$file" 2>/dev/null; then
echo "ESLint reports unresolved problems in $file — review before continuing." >&2
exit 1 # non-blocking: stderr is shown, the action still proceeds
fi
exit 0
The agent kicks off a build inside a Bash command and moves on, missing whether it actually succeeded. A post-command check pins the result to the transcript.
Recipe · bash
#!/usr/bin/env bash
# PostToolUse hook on Bash. When the command that just ran was a build,
# re-inspect the workspace and surface a clear pass/fail marker for the agent.
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
# Only react to build-shaped commands.
printf '%s' "$cmd" | grep -qE '(npm|pnpm|yarn) run build|make( |$)|go build|cargo build' || exit 0
if [ -f package.json ] && grep -q '"build"' package.json; then
if npm run build --silent >/dev/null 2>&1; then
echo 'BUILD OK' >&2
else
echo 'BUILD FAILED — fix before continuing.' >&2
exit 2
fi
fi
exit 0
Different files need different checks — JSON needs schema/format, Go needs vet, Python needs a linter. One gate that dispatches by file type keeps every edit clean without per-language wiring.
The agent writes commit messages that don't follow Conventional Commits, breaking changelog generation and semantic-release. Catch the bad message before the commit runs.
Recipe · bash
#!/usr/bin/env bash
# PreToolUse hook on Bash. Inspects `git commit -m` invocations and blocks
# any subject line that isn't Conventional Commits (type(scope): summary).
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
# Only look at git commit commands that carry an inline message.
printf '%s' "$cmd" | grep -q 'git commit' || exit 0
msg=$(printf '%s' "$cmd" | grep -oE -- '-m[[:space:]]+"[^"]+"' | head -1 | sed -E 's/^-m[[:space:]]+"//; s/"$//')
[ -n "$msg" ] || exit 0
if ! printf '%s' "$msg" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._-]+\))?!?: .+'; then
echo "Commit message must follow Conventional Commits, e.g. 'feat(auth): add SSO login'." >&2
echo "Got: $msg" >&2
exit 2
fi
exit 0
One `rm -rf`, force-push, or `DROP TABLE` from an over-eager agent is unrecoverable. The harness should refuse destructive commands rather than trust the model to be careful.
Recipe · bash
#!/usr/bin/env bash
# PreToolUse hook on Bash. Denies a curated set of irreversible commands
# via a JSON permission decision (recommended over a bare exit 2).
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
[ -n "$cmd" ] || exit 0
# Patterns that are almost never intentional from an agent.
danger='rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f|git[[:space:]]+push[[:space:]].*--force([^-]|$)|git[[:space:]]+reset[[:space:]]+--hard|DROP[[:space:]]+(TABLE|DATABASE)|mkfs|dd[[:space:]]+if=|:(){ :|kubectl[[:space:]]+delete|docker[[:space:]]+system[[:space:]]+prune'
if printf '%s' "$cmd" | grep -qiE "$danger"; then
jq -n --arg c "$cmd" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("Blocked destructive command: " + $c)
}
}'
exit 0
fi
exit 0
The agent hits a failing pre-commit hook and "solves" it by adding `--no-verify`, bypassing every git-side lint, test, and commit-lint gate at once. Nothing else catches this because git push doesn't receive the flag downstream.
Recipe · bash
#!/usr/bin/env bash
# PreToolUse hook on Bash. Hard-blocks the two escape hatches that
# silently disable your git-side quality gates: --no-verify and bare --force.
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
[ -n "$cmd" ] || exit 0
printf '%s' "$cmd" | grep -q 'git' || exit 0
if printf '%s' "$cmd" | grep -q -- '--no-verify'; then
echo 'BLOCKED: --no-verify bypasses pre-commit/commit-msg/pre-push hooks. Fix the underlying error instead.' >&2
exit 2
fi
if printf '%s' "$cmd" | grep -qE 'git[[:space:]]+push[[:space:]].*--force([^-]|$)'; then
echo 'BLOCKED: bare --force. Use --force-with-lease if a force push is truly required.' >&2
exit 2
fi
exit 0
During a scoped task the agent wanders and edits files far outside the intended directory. A freeze boundary keeps writes inside the area you actually authorized.
Recipe · bash
#!/usr/bin/env bash
# PreToolUse hook on Edit|Write. Blocks writes to any path outside a
# frozen boundary directory recorded in .claude/freeze-dir. Reads and searches
# are unaffected — only mutations are fenced.
set -euo pipefail
boundary_file=".claude/freeze-dir"
[ -f "$boundary_file" ] || exit 0 # no freeze active
boundary=$(cat "$boundary_file")
input=$(cat)
file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
[ -n "$file" ] || exit 0
abs=$(cd "$(dirname "$file")" 2>/dev/null && pwd)/$(basename "$file")
case "$abs" in
"$boundary"*) exit 0 ;;
*)
echo "BLOCKED: $file is outside the frozen boundary ($boundary)." >&2
exit 2 ;;
esac
When an agent has shell access on a real machine, a stray write to /etc, a firewall flush, or a `curl | sudo sh` can take the host down. System-level operations need a harder fence than project files.
Recipe · bash
#!/usr/bin/env bash
# PreToolUse hook on Bash. Blocks operations that touch system paths or
# disable host protections — the class of command that has no place in normal dev work.
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
[ -n "$cmd" ] || exit 0
sysdanger='rm[[:space:]]+-rf[[:space:]]+/(etc|var|usr|boot)|chmod[[:space:]]+-R[[:space:]]+777[[:space:]]+/|iptables[[:space:]]+-F|ufw[[:space:]]+disable|curl[[:space:]].*\|[[:space:]]*sudo[[:space:]]+(sh|bash)|systemctl[[:space:]]+(stop|disable)[[:space:]]+(firewalld|ufw)'
if printf '%s' "$cmd" | grep -qiE "$sysdanger"; then
echo "BLOCKED: system-level destructive operation refused by host guard." >&2
exit 2
fi
exit 0
The agent stages a file that contains an API key, an AWS secret, or a private key and commits it. Once pushed, the secret is compromised forever. Scan staged content before the commit runs.
Recipe · bash
#!/usr/bin/env bash
# PreToolUse hook on Bash. Before a git commit runs, scan the staged diff
# for high-signal secret patterns and block the commit if any match.
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
printf '%s' "$cmd" | grep -q 'git commit' || exit 0
patterns=(
'AKIA[0-9A-Z]{16}' # AWS access key id
'gh[pousr]_[A-Za-z0-9]{36,}' # GitHub token
'xox[baprs]-[A-Za-z0-9-]{10,}' # Slack token
'sk_live_[0-9a-zA-Z]{24,}' # Stripe live key
'-----BEGIN[ A-Z]*PRIVATE KEY-----' # private key block
)
staged=$(git diff --cached -U0 2>/dev/null || true)
for p in "${patterns[@]}"; do
if printf '%s' "$staged" | grep -qE "$p"; then
echo "BLOCKED: possible secret ($p) in staged changes. Remove it and use a secret manager." >&2
exit 2
fi
done
exit 0
Long sessions silently approach the context limit and then compact at the worst moment, dropping state mid-task. A running warning lets the agent checkpoint and compact deliberately.
Recipe · bash
#!/usr/bin/env bash
# PostToolUse hook. Estimates transcript size and nudges the agent to
# checkpoint + compact once it crosses warning thresholds. Debounced so it doesn't
# fire on every single tool call.
set -euo pipefail
input=$(cat)
transcript=$(printf '%s' "$input" | jq -r '.transcript_path // empty')
[ -n "$transcript" ] && [ -f "$transcript" ] || exit 0
# Rough proxy for context pressure: transcript bytes vs a budget.
budget=$((700 * 1024))
size=$(wc -c < "$transcript")
pct=$(( size * 100 / budget ))
if [ "$pct" -ge 80 ]; then
echo "CONTEXT ~${pct}%: write a checkpoint to a file and run /compact before continuing." >&2
elif [ "$pct" -ge 65 ]; then
echo "CONTEXT ~${pct}%: consider summarizing progress to an external file soon." >&2
fi
exit 0
You have no idea how many tool calls a long session made until it is over. A lightweight counter appended on every tool call gives you a live activity ledger to review.
Recipe · bash
#!/usr/bin/env bash
# PostToolUse hook on every tool. Appends one tab-separated line per tool
# call to a per-session ledger you can tail or aggregate later.
set -euo pipefail
input=$(cat)
session=$(printf '%s' "$input" | jq -r '.session_id // "unknown"')
tool=$(printf '%s' "$input" | jq -r '.tool_name // "?"')
log_dir="${CLAUDE_PROJECT_DIR:-.}/.claude/logs"
mkdir -p "$log_dir"
printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$session" "$tool" \
>> "$log_dir/tool-activity.tsv"
exit 0
Every new session starts cold — the agent re-derives project context it learned last time. A SessionStart hook injects the essentials (recent branch, open tasks, house rules) up front.
Recipe · bash
#!/usr/bin/env bash
# SessionStart hook. Emits a compact orientation block as additionalContext
# so the agent begins with branch, recent commits, and any open task list loaded.
set -euo pipefail
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'n/a')
recent=$(git log --oneline -5 2>/dev/null || echo 'no git history')
tasks=''
[ -f TODO.md ] && tasks=$(head -20 TODO.md)
context=$(printf 'Branch: %s\n\nRecent commits:\n%s\n\nOpen tasks:\n%s\n' \
"$branch" "$recent" "${tasks:-none}")
jq -n --arg ctx "$context" '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: $ctx
}
}'
exit 0
In a knowledge-base or notes repo, the agent needs the shape of the workspace — which files exist, what the methodology is — before it can act usefully. Inject a structure map at session start.
Recipe · bash
#!/usr/bin/env bash
# SessionStart hook. Injects a shallow map of the workspace's markdown
# files plus any persisted identity/methodology docs as additionalContext.
set -euo pipefail
structure=$(find . -maxdepth 3 -name '*.md' -not -path '*/node_modules/*' 2>/dev/null | head -40)
identity=''
[ -f .agent/identity.md ] && identity=$(cat .agent/identity.md)
context=$(printf 'Workspace markdown files:\n%s\n\nIdentity / methodology:\n%s\n' \
"$structure" "${identity:-none}")
jq -n --arg ctx "$context" '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: $ctx
}
}'
exit 0
When a long-running agent needs attention or finishes, nobody is watching the terminal. Forward its notifications to Slack (or any webhook) so the human gets pinged.
Recipe · bash
#!/usr/bin/env bash
# Notification hook. Forwards the notification message to a Slack-style
# incoming webhook. Runs in the background so it never blocks the agent.
set -euo pipefail
: "${SLACK_WEBHOOK_URL:?set SLACK_WEBHOOK_URL}"
input=$(cat)
msg=$(printf '%s' "$input" | jq -r '.message // "Claude Code needs attention."')
payload=$(jq -n --arg t "[Claude Code] $msg" '{text: $t}')
curl -s -X POST -H 'Content-Type: application/json' \
--max-time 10 -d "$payload" "$SLACK_WEBHOOK_URL" >/dev/null 2>&1 &
exit 0
The agent opens a PR via `gh pr create` and stops, leaving reviewers unpinged and the checklist unmentioned. A follow-up hook posts a next-steps note the moment the PR lands.
Recipe · bash
#!/usr/bin/env bash
# PostToolUse hook on Bash. Detects a `gh pr create` and reminds the agent
# (and, optionally, a webhook) that the PR now needs reviewers and a passing CI run.
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
printf '%s' "$cmd" | grep -qE 'gh pr create' || exit 0
url=$(gh pr view --json url -q .url 2>/dev/null || echo '')
echo "PR opened${url:+: $url}. Next: request reviewers, watch CI, and address review comments before merge." >&2
if [ -n "${SLACK_WEBHOOK_URL:-}" ] && [ -n "$url" ]; then
curl -s -X POST -H 'Content-Type: application/json' --max-time 10 \
-d "$(jq -n --arg t "New PR ready for review: $url" '{text:$t}')" \
"$SLACK_WEBHOOK_URL" >/dev/null 2>&1 &
fi
exit 0
The agent pushes before anyone has looked at the diff. A pre-push reminder forces a beat to confirm the change set is intended and the branch is right.
Recipe · bash
#!/usr/bin/env bash
# PreToolUse hook on Bash. On a `git push`, emit a short review reminder
# (non-blocking) so the change gets a conscious check before it leaves the machine.
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
printf '%s' "$cmd" | grep -qE 'git[[:space:]]+push' || exit 0
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')
ahead=$(git rev-list --count '@{u}..HEAD' 2>/dev/null || echo '?')
echo "About to push $ahead commit(s) on '$branch'. Confirm the diff and target branch are correct." >&2
exit 0 # reminder only; the push proceeds
An agent edit can clobber a file in a way that's awkward to recover from mid-session. A cheap pre-edit snapshot gives you an instant local rollback point.
Recipe · bash
#!/usr/bin/env bash
# PreToolUse hook on Edit|Write. Copies the target file to a timestamped
# backup under .claude/backups before the edit is applied.
set -euo pipefail
input=$(cat)
file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
[ -n "$file" ] && [ -f "$file" ] || exit 0 # nothing to back up for new files
backup_dir="${CLAUDE_PROJECT_DIR:-.}/.claude/backups"
mkdir -p "$backup_dir"
stamp=$(date -u +%Y%m%dT%H%M%SZ)
safe=$(printf '%s' "$file" | tr '/' '_')
cp "$file" "$backup_dir/${stamp}__${safe}"
exit 0
In a notes or content vault, you want every agent change captured in git history without asking. An auto-commit hook turns each write into a recoverable, attributable checkpoint.
Recipe · bash
#!/usr/bin/env bash
# PostToolUse hook on Write. Stages and commits changes automatically with
# a generated message. Intended for content/notes repos, not application code.
set -euo pipefail
# Guard: only run when explicitly opted in, to avoid noisy commits.
[ -f .claude/auto-commit-enabled ] || exit 0
changed=$(git status --porcelain | wc -l | tr -d ' ')
[ "$changed" -gt 0 ] || exit 0
git add -A
git commit -m "chore(auto): capture $changed changed file(s) [skip ci]" \
--no-verify >/dev/null 2>&1 || true
exit 0
Debug `console.log` / `print` statements slip into committed code because nothing flags them at edit time. A quick scan on each edit keeps stray debug output out of the tree.
Recipe · bash
#!/usr/bin/env bash
# PostToolUse hook on Edit|Write. Warns when the edited file contains
# leftover debug logging. Non-blocking — a nudge, not a wall.
set -euo pipefail
input=$(cat)
file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
[ -n "$file" ] && [ -f "$file" ] || exit 0
case "$file" in
*.js|*.jsx|*.ts|*.tsx)
if grep -nE 'console\.(log|debug)' "$file" >/dev/null; then
echo "Debug console.* left in $file — remove before committing." >&2
fi ;;
*.py)
if grep -nE '^[[:space:]]*print\(' "$file" >/dev/null; then
echo "Stray print() left in $file — use logging or remove." >&2
fi ;;
esac
exit 0
A review sub-agent "finishes" with a vague thumbs-up and no actionable findings. Gate the stop on structured output — require concrete findings with severities before the review counts as done.
Recipe · bash
#!/usr/bin/env bash
# Stop hook for a review agent. Requires the transcript's final message to
# contain at least two severity-tagged findings; otherwise blocks the stop.
set -uo pipefail
input=$(cat)
transcript=$(printf '%s' "$input" | jq -r '.transcript_path // empty')
[ -n "$transcript" ] && [ -f "$transcript" ] || exit 0
# Last assistant message text.
last=$(tail -50 "$transcript")
findings=$(printf '%s' "$last" | grep -oiE '(CRITICAL|HIGH|MEDIUM|LOW)' | wc -l | tr -d ' ')
if [ "$findings" -lt 2 ]; then
printf '%s' '{"decision":"block","reason":"Review must list at least two severity-tagged findings (CRITICAL/HIGH/MEDIUM/LOW) with specifics. Re-examine and report concretely."}'
exit 0
fi
exit 0
When safety hooks deny commands, you lose the signal — there's no record of what the agent tried and got blocked on. An append-only audit log turns denials into a reviewable trail.
Recipe · bash
#!/usr/bin/env bash
# PostToolUse hook. Records blocked/denied tool activity to an append-only
# log — tool name and reason only, never full arguments, to avoid leaking secrets.
set -euo pipefail
input=$(cat)
tool=$(printf '%s' "$input" | jq -r '.tool_name // "?"')
decision=$(printf '%s' "$input" | jq -r '.tool_response.permissionDecision // empty')
# Only log denials.
[ "$decision" = "deny" ] || exit 0
log="${CLAUDE_PROJECT_DIR:-.}/.claude/logs/denied-commands.log"
mkdir -p "$(dirname "$log")"
printf '%s\ttool=%s\tdecision=deny\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$tool" >> "$log"
# Rotate at ~100KB.
if [ "$(wc -c < "$log")" -gt 102400 ]; then tail -500 "$log" > "$log.tmp" && mv "$log.tmp" "$log"; fi
exit 0
Agents love to create README.md, SUMMARY.md, and NOTES.md files nobody asked for, cluttering the repo. Warn (or block) on unsolicited documentation-file creation.
Recipe · bash
#!/usr/bin/env bash
# PreToolUse hook on Write. Warns when the agent tries to create a new
# top-level markdown/doc file, a common source of repo clutter. Non-blocking.
set -euo pipefail
input=$(cat)
file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
[ -n "$file" ] || exit 0
# Already exists? Then it's an edit, not sprawl.
[ -f "$file" ] && exit 0
case "$(basename "$file")" in
*.md|*.MD|*.rst)
echo "Creating a new doc file ($file). Confirm this was requested — prefer editing existing docs over adding new ones." >&2 ;;
esac
exit 0
Sessions end without a trace, so you can't reconstruct when work happened or how long it ran. A SessionEnd marker writes a durable full-stop you can grep later.
Recipe · bash
#!/usr/bin/env bash
# SessionEnd hook. Appends a durable end-of-session marker (timestamp,
# session id, branch) to a log for later reconstruction. Output is ignored by CC.
set -euo pipefail
input=$(cat)
session=$(printf '%s' "$input" | jq -r '.session_id // "unknown"')
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'n/a')
log="${CLAUDE_PROJECT_DIR:-.}/.claude/logs/sessions.log"
mkdir -p "$(dirname "$log")"
printf '%s\tsession=%s\tbranch=%s\tevent=end\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$session" "$branch" >> "$log"
exit 0
Reimplementing command-safety heuristics in shell is error-prone and hard to keep current. Delegate the whole PreToolUse decision to a maintained safety-net tool instead of hand-rolling patterns.
Recipe · bash
// hooks/hooks.json — register a maintained checker as the PreToolUse handler.
// The tool reads the hook JSON on stdin and emits the permission decision,
// so your settings stay tiny and the rules live in a versioned dependency.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/cc-safety-net.js\" hook --claude-code"
}
]
}
]
}
}
You want a session to begin in a specific posture (strict review, intensity level, house ruleset) without pasting instructions every time. A SessionStart hook activates the mode and injects its rules.
Recipe · bash
#!/usr/bin/env bash
# SessionStart hook. Reads a mode from .claude/mode (default: standard),
# writes an active-mode flag, and injects the matching ruleset as additionalContext.
set -euo pipefail
mode='standard'
[ -f .claude/mode ] && mode=$(cat .claude/mode)
printf '%s' "$mode" > .claude/.mode-active
case "$mode" in
strict) rules='Strict mode: no stubs, tests-first, no --no-verify, small diffs.' ;;
*) rules='Standard mode: follow repo conventions and CLAUDE.md.' ;;
esac
jq -n --arg ctx "$rules" '{
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: $ctx }
}'
exit 0
Hooks are deterministic scripts Claude Code runs at defined points in its lifecycle — before or after a tool call (PreToolUse / PostToolUse), when a session starts or ends, when it sends a notification, and more. They receive a JSON payload on stdin and can allow, block, or annotate what the agent is about to do. Unlike a prompt, a hook is a guarantee: a linter that blocks bad code enforces the rule every time.
How is a hook different from a skill or an MCP server?
A skill packages knowledge and procedure the model loads on demand; an MCP server gives the model typed access to an external system; a hook is deterministic enforcement wired into the runtime. Skills and MCP expand what the agent can do — hooks bound what it is allowed to do and fire without the model choosing to invoke them. See our breakdown of Skills vs MCP vs Hooks for the full comparison.
Which hook event should I use to run a check after the agent edits a file?
Use PostToolUse with a matcher of "Edit|Write". The hook receives tool_input.file_path, so it can run a formatter, type-checker, or linter against just the file that changed. Exit 0 to pass, exit 2 to block the turn with an error the agent must address.
How does a hook block a dangerous command?
A PreToolUse hook can block in two ways: exit with code 2 and write the reason to stderr, or exit 0 and print a JSON object with hookSpecificOutput.permissionDecision set to "deny". The JSON form is recommended because it gives the agent a structured reason. Both stop the tool call before it runs.
Where do I register a hook?
Hooks live under the "hooks" key in .claude/settings.json (project), ~/.claude/settings.json (all projects), or a plugin's hooks.json. Each entry pairs a matcher (which tool the hook applies to) with a command to run. Every recipe on this page includes the settings.json snippet to register it.