Headless Notes

Claude Code Hooks Examples: 7 Copy-Paste Scripts

By Headless Notes · Published · 10 min read

Claude Code hooks are shell commands that run at fixed points in a session. Common ones run before a tool call (PreToolUse), after one (PostToolUse), when Claude tries to finish (Stop) and when a session ends (SessionEnd). This guide has seven copy-paste scripts: two guardrails that block dangerous commands and protected files, three stall-prevention hooks (test gate, "waiting for you" alerts, context at startup) and two logging hooks that record every tool call and session. Each script is a small bash file in .claude/hooks/, wired up in .claude/settings.json (Hooks reference).

Which option is right for you?

Start with the one hook that fixes the problem you have today, then add others as you need them.

Your situationWhat to pick firstFirst page to open
Beginner, first hook everScript 4 (notification when Claude is waiting). It changes nothing and is easy to checkHooks guide
Daily work, Claude runs commands unattendedScripts 1 + 2 (guardrails)Hooks reference: PreToolUse
Overnight or cron runs that stop earlyScript 3 (Stop test gate) + Script 5 (tool log)/claude-code-cron-job-setup-run-automations-overnight/
Watching your budget or usageScripts 5 + 6 (logging)/claude-code-how-to-see-token-usage-and-reduce-it/

Why these picks:

If you haven't read about the events yet, start with /claude-code-hooks-lifecycle-every-event-explained/.

How hooks are wired: the 60-second version

Every hook is a JSON entry under an event name in a settings file. Claude Code sends event data to the script on stdin, and the script's exit code or JSON output controls what happens next (Hooks reference).

Key facts from the official reference:

All scripts below need jq to parse the JSON on stdin.

Setup steps (do this once)

  1. In your project root, run mkdir -p .claude/hooks .claude/logs.
  2. Save each script below into .claude/hooks/ and make it executable: chmod +x .claude/hooks/*.sh.
  3. Add the matching JSON block to .claude/settings.json under the top-level "hooks" key. If you use several hooks, merge them into one "hooks" object.
  4. In Claude Code, type /hooks. The menu is a read-only browser that shows each hook, its event and which file it came from (Hooks reference).
  5. Trigger the event on purpose (for example, ask Claude to run rm -rf /tmp/x) and check that the hook fires.

Guardrail scripts (1–2): stop damage before it happens

Guardrails use PreToolUse, which runs before a tool call and can allow, deny, ask or defer it (Hooks reference).

Script 1: Block destructive shell commands

.claude/hooks/block-dangerous.sh:

#!/usr/bin/env bash
# Example guardrail: deny risky Bash commands before they run.
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$CMD" | grep -Eq 'rm -rf|git push --force|git reset --hard|DROP TABLE'; then
  jq -n --arg c "$CMD" '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: ("Blocked by guardrail hook: " + $c)
    }
  }'
fi
exit 0
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.sh" }] }
    ]
  }
}

The permissionDecision: "deny" output is the documented way to block a call. If the script prints nothing and exits 0, the normal permission flow applies (Hooks reference). Limitation: pattern matching is easy to get around (for example, rm -r -f). Treat it as a seatbelt, not a sandbox, and keep your normal permission rules too.

Script 2: Protect secrets and lock files

.claude/hooks/protect-files.sh:

#!/usr/bin/env bash
# Example: refuse edits to sensitive paths. Exit 2 = block; stderr is the reason.
FILE=$(jq -r '.tool_input.file_path // empty')
for p in ".env" "package-lock.json" ".git/" "secrets/"; do
  if [[ "$FILE" == *"$p"* ]]; then
    echo "Edits to $FILE are blocked (protected path: $p)." >&2
    exit 2
  fi
done
exit 0

Matcher: "Edit|Write". On PreToolUse, exit code 2 blocks the call, and your stderr message goes to Claude as the reason, so it can change course instead of retrying blindly (Hooks reference).

Stall-prevention scripts (3–5): keep unattended runs moving

These hooks handle the three usual stalls: Claude declaring "done" too early, Claude sitting at a prompt while you're away, and Claude starting without context. For the wider set of causes, see /why-claude-code-stops-mid-automation-and-how-to-fix-it/.

Script 3: Don't let Claude stop while tests fail

.claude/hooks/stop-gate.sh:

#!/usr/bin/env bash
# Example Stop gate: block "done" once if tests fail.
INPUT=$(cat)
# Loop guard: if we already forced a continuation, let Claude stop.
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active // false')" = "true" ]; then
  exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
if ! npm test --silent > .claude/logs/last-test.log 2>&1; then
  jq -n '{decision: "block",
          reason: "Tests are failing. Read .claude/logs/last-test.log, fix the failures, then finish."}'
fi
exit 0
"Stop": [
  { "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/stop-gate.sh", "timeout": 300 }] }
]

On Stop, decision: "block" with a reason keeps Claude working, and the reason becomes its next instruction (Hooks reference). The stop_hook_active field is true when Claude is already continuing because of a Stop hook. Check it, or you can create a loop that never ends (cubic, claudefa.st). One third-party write-up says Claude Code ends the turn itself after eight blocks in a row (cubic). Don't rely on that. Not for: projects whose test suite takes longer than your timeout. Swap in a faster check such as lint or type-check.

Script 4: Get pinged when Claude is waiting for you

.claude/hooks/notify.sh:

#!/usr/bin/env bash
# Example: log + desktop alert when Claude needs input.
INPUT=$(cat)
MSG=$(echo "$INPUT" | jq -r '.message // "Claude Code needs your input"')
echo "$(date -u +%FT%TZ) $MSG" >> "$CLAUDE_PROJECT_DIR/.claude/logs/waiting.log"
if command -v osascript >/dev/null; then
  osascript -e "display notification \"$MSG\" with title \"Claude Code\""
elif command -v notify-send >/dev/null; then
  notify-send "Claude Code" "$MSG"
fi
exit 0

Put it under the Notification event with matcher "permission_prompt|idle_prompt". Both are documented notification types (Hooks reference). On a headless server the desktop alert does nothing, but waiting.log still shows you where an overnight run got stuck.

Script 5 (bonus context): Brief Claude at session start

.claude/hooks/session-context.sh:

#!/usr/bin/env bash
# Example: give Claude the current repo state at startup.
cd "$CLAUDE_PROJECT_DIR" || exit 0
echo "Branch: $(git branch --show-current)"
echo "Uncommitted files:"; git status --short | head -20
[ -f TODO.md ] && { echo "Open TODOs:"; head -20 TODO.md; }
exit 0

On SessionStart, plain stdout is added to Claude's context. That's different from most events, where it goes to the debug log (cubic). Keep it short: stdout fields are capped at 10,000 characters, and anything longer is saved to a file with only a preview shown (Hooks reference).

Usage-logging scripts (6–7): know what the agent actually did

Logging hooks add one JSON line per event to a file, so you can review a run afterwards without reading the whole transcript.

Script 6: Log every tool call

.claude/hooks/log-tools.sh:

#!/usr/bin/env bash
# Example: one JSON line per tool call.
jq -c '{ts: (now|todate), session: .session_id, tool: .tool_name,
        target: (.tool_input.command // .tool_input.file_path // .tool_input.url // null)}' \
  >> "$CLAUDE_PROJECT_DIR/.claude/logs/tools.jsonl"
exit 0
"PostToolUse": [
  { "matcher": "*",
    "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.sh", "async": true }] }
]

"async": true is a documented handler option (Hooks reference). Use it so logging doesn't slow each tool call down.

Script 7: Session summary on exit

.claude/hooks/session-summary.sh:

#!/usr/bin/env bash
# Example: summarize a session when it ends.
INPUT=$(cat)
SID=$(echo "$INPUT" | jq -r '.session_id')
REASON=$(echo "$INPUT" | jq -r '.reason // "unknown"')
LOG="$CLAUDE_PROJECT_DIR/.claude/logs/tools.jsonl"
COUNT=$(grep -c "\"$SID\"" "$LOG" 2>/dev/null || echo 0)
TOP=$(grep "\"$SID\"" "$LOG" 2>/dev/null | jq -r '.tool' | sort | uniq -c | sort -rn | head -3 | tr '\n' ' ')
echo "$(date -u +%FT%TZ) session=$SID reason=$REASON tool_calls=$COUNT top=[$TOP] transcript=$(echo "$INPUT" | jq -r '.transcript_path')" \
  >> "$CLAUDE_PROJECT_DIR/.claude/logs/sessions.log"
exit 0

Register it under SessionEnd. Documented end reasons include clear, logout, prompt_input_exit and other (Hooks reference). Limitation: this counts tool calls, not tokens or cost. For token numbers, use the tools in /claude-code-how-to-see-token-usage-and-reduce-it/.

Common mistakes and fixes

Most broken hooks come down to one of five problems:

SymptomLikely causeFix
Hook never firesWrong event name or matcher (names are case-sensitive, e.g. Bash, `Edit\Write`)Open /hooks and confirm the event, matcher and source file
"hook error" noticeScript isn't executable or jq is missingRun chmod +x, install jq, and test with `echo '{}' \./script.sh`
JSON output ignoredExited with a code other than 0, or extra text was printed before the JSONPrint only the JSON, then exit 0
Claude never finishesStop hook ignores stop_hook_activeAdd the loop guard from Script 3
Works in one folder, not anotherRelative pathsUse ${CLAUDE_PROJECT_DIR}

For anything else, turn on debug logging. It shows hook runs, full stderr and JSON parse failures (Hooks reference, debug logs). If a hook goes wrong and Claude edits files it shouldn't, /claude-code-checkpoints-how-rollback-works-in-automations/ explains how to roll back.

Next steps

The fastest win is one guardrail and one log today, then a test gate once both are working.

Today, on your own:

  1. Add Script 1 and Script 6 to one project, then use /hooks to check both are registered.
  2. Run a normal task and read .claude/logs/tools.jsonl afterwards.
  3. When it looks right, add Script 3 with your fastest test command.

If you'd rather not build this yourself:

Related guides on this site:

FAQ

Where do I put Claude Code hooks?

Put them in .claude/settings.json for a project you share, .claude/settings.local.json for personal project hooks, or ~/.claude/settings.json for all your projects. The scripts themselves can live anywhere, but .claude/hooks/ plus ${CLAUDE_PROJECT_DIR} keeps paths reliable (Hooks reference).

How do I block a command with a hook?

Use a PreToolUse hook that either prints JSON with permissionDecision: "deny" or exits with code 2 and a reason on stderr. Both stop the tool call, and Claude sees the reason (Hooks reference).

Why does my Stop hook make Claude loop forever?

The hook blocks every stop, even after Claude has already been forced to continue. Read stop_hook_active from stdin and exit 0 when it's true (cubic).

Do hooks slow Claude Code down?

Synchronous hooks run inline, and command hooks can run for up to 600 seconds by default. Keep checks fast, set a lower timeout, and mark logging hooks "async": true (Hooks reference).

Sources

Spotted something out of date or wrong? Tell us at shigoto.ai.note@gmail.com. We check every guide against its official sources.