Claude Code Hooks Examples: 7 Copy-Paste Scripts
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 situation | What to pick first | First page to open |
|---|---|---|
| Beginner, first hook ever | Script 4 (notification when Claude is waiting). It changes nothing and is easy to check | Hooks guide |
| Daily work, Claude runs commands unattended | Scripts 1 + 2 (guardrails) | Hooks reference: PreToolUse |
| Overnight or cron runs that stop early | Script 3 (Stop test gate) + Script 5 (tool log) | /claude-code-cron-job-setup-run-automations-overnight/ |
| Watching your budget or usage | Scripts 5 + 6 (logging) | /claude-code-how-to-see-token-usage-and-reduce-it/ |
Why these picks:
- Beginner: a notification hook can't block anything, so if it's wrong, nothing breaks.
- Daily use: guardrails are deterministic. They always run, so you don't depend on the model remembering a rule (Hooks guide).
- Budget: logs show you what the agent actually did before you change plans or prompts.
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:
- Where:
~/.claude/settings.json(all your projects),.claude/settings.json(project, can be committed) or.claude/settings.local.json(project, gitignored). - Input on stdin:
session_id,transcript_path,cwd,permission_mode,hook_event_name, plus event fields liketool_nameandtool_input. - Exit codes:
0= success, and any JSON output is applied.2= blocking error on events that support blocking. Any other code = non-blocking error, and the action still goes ahead. - Timeout: 600 seconds by default for command hooks. You can change it with
"timeout". - Matcher:
"Bash","Edit|Write", or a regex. Leave it empty or use"*"to match everything. - Paths: use
${CLAUDE_PROJECT_DIR}so scripts work from any working directory.
All scripts below need jq to parse the JSON on stdin.
Setup steps (do this once)
- In your project root, run
mkdir -p .claude/hooks .claude/logs. - Save each script below into
.claude/hooks/and make it executable:chmod +x .claude/hooks/*.sh. - Add the matching JSON block to
.claude/settings.jsonunder the top-level"hooks"key. If you use several hooks, merge them into one"hooks"object. - 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). - 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:
| Symptom | Likely cause | Fix | |
|---|---|---|---|
| Hook never fires | Wrong 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" notice | Script isn't executable or jq is missing | Run chmod +x, install jq, and test with `echo '{}' \ | ./script.sh` |
| JSON output ignored | Exited with a code other than 0, or extra text was printed before the JSON | Print only the JSON, then exit 0 | |
| Claude never finishes | Stop hook ignores stop_hook_active | Add the loop guard from Script 3 | |
| Works in one folder, not another | Relative paths | Use ${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:
- Add Script 1 and Script 6 to one project, then use
/hooksto check both are registered. - Run a normal task and read
.claude/logs/tools.jsonlafterwards. - When it looks right, add Script 3 with your fastest test command.
If you'd rather not build this yourself:
- Claude Academy's "Claude Code in Action": a free course of about one hour and 9 lessons, including using hooks to enforce rules (Skilljar, Class Central). Good if you want structured basics. It isn't for you if you need help with your specific setup, because it's general training.
- Claude Team plan: $25/seat/month, or $20 billed annually (as of October 2026; check the official page). Useful if a small team wants shared project settings, and admins can restrict hooks with managed settings such as
allowManagedHooksOnly(Hooks reference). Not worth it for a solo builder: Pro ($20/month, or $17 billed annually, as of October 2026; see Claude pricing) already includes Claude Code. Limitation: no plan writes your hooks for you.
Related guides on this site:
- /claude-code-hooks-lifecycle-every-event-explained/
- /why-claude-code-stops-mid-automation-and-how-to-fix-it/
- /claude-code-cron-job-setup-run-automations-overnight/
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
- Hooks reference – Claude Code Docs
- Automate actions with hooks (Hooks guide) – Claude Code Docs
- Permissions – Claude Code Docs
- Debug logs – Claude Code Docs
- Claude Code hooks: every event, with examples – cubic
- Claude Code Stop Hook: Force Task Completion – claudefa.st
- Claude pricing
- Claude Code in Action – Anthropic Courses
- Claude Code in Action – Class Central
- jq
Spotted something out of date or wrong? Tell us at shigoto.ai.note@gmail.com. We check every guide against its official sources.