Patterns & Recipes

Claude Code Hooks

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.

01Lifecycle

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.

PreToolUse

Before a tool runs — can block or rewrite it.

PostToolUse

After a tool succeeds — verify, format, lint.

Stop

When the turn ends — gate 'done' on real checks.

SessionStart

On session start — inject context and rules.

SessionEnd

On session end — write durable markers.

Notification

On a notification — forward to Slack / webhook.

Full event reference: Claude Code hooks documentation.

02Library

28 Hook Patterns

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.

PostToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/typecheck-gate.sh" }
        ]
      }
    ]
  }
}

Requirements

  • TypeScript project with a tsconfig.json
  • npx / tsc resolvable in PATH
  • jq for parsing the hook payload

Seen in

StopNeeds configclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/test-gate.sh" }
        ]
      }
    ]
  }
}

Requirements

  • A test runner for your stack (npm test / pytest / go test)
  • jq to build the block-decision JSON
  • Fast-enough suite that running it every turn is tolerable

Seen in

PostToolUsePortableclaude-code

Agent-written files drift from house formatting, producing noisy diffs and pre-commit churn. Formatting should be automatic, not a review comment.

Recipe · bash
#!/usr/bin/env bash
# PostToolUse hook on Edit|Write. Auto-formats the edited file with the
# project formatter (Biome or Prettier), preferring the local binary. Silent no-op
# when no formatter is installed.
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|*.json|*.css|*.md) ;;
  *) exit 0 ;;
esac

if [ -x node_modules/.bin/biome ]; then
  node_modules/.bin/biome check --write "$file" >/dev/null 2>&1 || true
elif [ -x node_modules/.bin/prettier ]; then
  node_modules/.bin/prettier --write "$file" >/dev/null 2>&1 || true
elif command -v npx >/dev/null 2>&1; then
  npx --no-install prettier --write "$file" >/dev/null 2>&1 || true
fi
exit 0
Register in settings.json · json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-on-edit.sh" }
        ]
      }
    ]
  }
}

Requirements

  • Biome or Prettier installed (local node_modules preferred)
  • jq for parsing the hook payload

Seen in

PostToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/lint-on-edit.sh" }
        ]
      }
    ]
  }
}

Requirements

  • ESLint configured for the project
  • jq for parsing the hook payload

Seen in

PostToolUseNeeds configclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/build-verify.sh" }
        ]
      }
    ]
  }
}

Requirements

  • A build script for your stack
  • jq for parsing the hook payload

Seen in

PostToolUsePortableclaude-code

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.

Recipe · bash
#!/usr/bin/env bash
# PostToolUse hook on Edit|Write. Lightweight per-file quality check that
# dispatches on extension and skips gracefully when tooling is absent.
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
  *.go)  command -v gofmt >/dev/null && gofmt -w "$file" || true ;;
  *.py)  command -v ruff  >/dev/null && ruff check --fix "$file" || true ;;
  *.json) command -v jq   >/dev/null && jq . "$file" >/dev/null || { echo "Invalid JSON: $file" >&2; exit 2; } ;;
  *.md)  [ -x node_modules/.bin/prettier ] && node_modules/.bin/prettier --write "$file" || true ;;
esac
exit 0
Register in settings.json · json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/quality-gate.sh" }
        ]
      }
    ]
  }
}

Requirements

  • Whatever per-language tools you want enforced (gofmt, ruff, jq, prettier)
  • jq for parsing the hook payload

Seen in

PreToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/commit-message-check.sh" }
        ]
      }
    ]
  }
}

Requirements

  • A Conventional Commits convention you want enforced
  • jq for parsing the hook payload

Seen in

PreToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/dangerous-command-block.sh" }
        ]
      }
    ]
  }
}

Requirements

  • jq for parsing the payload and emitting the decision JSON
  • Tune the pattern list to your own irreversible operations

Seen in

PreToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/no-verify-block.sh" }
        ]
      }
    ]
  }
}

Requirements

  • Git-side hooks worth protecting (pre-commit, commit-msg, pre-push)
  • jq for parsing the hook payload

Seen in

PreToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protected-path-freeze.sh" }
        ]
      }
    ]
  }
}

Requirements

  • A `.claude/freeze-dir` file holding the absolute boundary path
  • jq for parsing the hook payload

Seen in

PreToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/system-path-guard.sh" }
        ]
      }
    ]
  }
}

Requirements

  • Only meaningful when the agent has real host shell access
  • jq for parsing the hook payload

Seen in

PreToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/secret-scan.sh" }
        ]
      }
    ]
  }
}

Requirements

  • Git repository with staged changes to scan
  • jq for parsing the hook payload
  • Extend the pattern list for your own token formats

Seen in

PostToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/context-usage-warning.sh" }
        ]
      }
    ]
  }
}

Requirements

  • jq for parsing the hook payload
  • Tune the budget to your model's context window

Seen in

PostToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/cost-tracker.sh" }
        ]
      }
    ]
  }
}

Requirements

  • jq for parsing the hook payload
  • A writable .claude/logs directory

Seen in

SessionStartPortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-start-log.sh" }
        ]
      }
    ]
  }
}

Requirements

  • jq to emit the additionalContext JSON
  • Git repo (optional TODO.md for task injection)

Seen in

SessionStartPortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-orient.sh" }
        ]
      }
    ]
  }
}

Requirements

  • jq to emit the additionalContext JSON
  • A markdown-centric workspace (optional .agent/identity.md)

Seen in

NotificationNeeds configclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "Notification": [
      {
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/notification-forward.sh" }
        ]
      }
    ]
  }
}

Requirements

  • A Slack (or compatible) incoming webhook URL in SLACK_WEBHOOK_URL
  • curl and jq available

Seen in

PostToolUseNeeds configclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/pr-created-followup.sh" }
        ]
      }
    ]
  }
}

Requirements

  • GitHub CLI (gh) authenticated
  • jq for parsing the hook payload
  • Optional SLACK_WEBHOOK_URL for the ping

Seen in

PreToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/git-push-reminder.sh" }
        ]
      }
    ]
  }
}

Requirements

  • Git repo with an upstream configured
  • jq for parsing the hook payload

Seen in

PreToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/file-backup-on-edit.sh" }
        ]
      }
    ]
  }
}

Requirements

  • jq for parsing the hook payload
  • A writable .claude/backups directory (add it to .gitignore)

Seen in

PostToolUseFramework-specificclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/auto-commit.sh" }
        ]
      }
    ]
  }
}

Requirements

  • A `.claude/auto-commit-enabled` opt-in flag file
  • Git repository (best for content/notes vaults, not app code)

Seen in

PostToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/console-log-guard.sh" }
        ]
      }
    ]
  }
}

Requirements

  • jq for parsing the hook payload

Seen in

StopFramework-specificclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/review-quality-gate.sh" }
        ]
      }
    ]
  }
}

Requirements

  • jq for parsing the hook payload
  • Best paired with a dedicated review agent, not general coding

Seen in

PostToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/denied-command-audit.sh" }
        ]
      }
    ]
  }
}

Requirements

  • jq for parsing the hook payload
  • A writable .claude/logs directory

Seen in

PreToolUsePortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/doc-file-guard.sh" }
        ]
      }
    ]
  }
}

Requirements

  • jq for parsing the hook payload

Seen in

SessionEndPortableclaude-code

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
Register in settings.json · json
{
  "hooks": {
    "SessionEnd": [
      {
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-end-marker.sh" }
        ]
      }
    ]
  }
}

Requirements

  • jq for parsing the hook payload
  • A writable .claude/logs directory

Seen in

PreToolUseFramework-specificclaude-code

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"
          }
        ]
      }
    ]
  }
}
Register in settings.json · json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/cc-safety-net.js\" hook --claude-code" }
        ]
      }
    ]
  }
}

Requirements

  • The safety-net package installed (provides the binary)
  • Node available on PATH

Seen in

SessionStartPortableclaude-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
Register in settings.json · json
{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/mode-activation.sh" }
        ]
      }
    ]
  }
}

Requirements

  • jq to emit the additionalContext JSON
  • A `.claude/mode` file (optional; defaults to standard)

Seen in

03FAQ

Claude Code Hooks FAQ

What are Claude Code hooks?

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.

04Next

Keep Going

Stay Updated with Claude Skills

Subscribe to get the latest Claude Skills, tutorials, and community highlights delivered to your inbox.

We respect your privacy. Unsubscribe at any time.