Claude Code writes code fast. Left alone it also forgets to format, occasionally drafts a command I never want run, and finishes work silently while I'm in another window. Hooks fix all three.
TL;DR: Put this in ~/.claude/settings.json (or project .claude/settings.json):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/format.sh" }
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/guard-bash.sh" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/notify.sh" }
]
}
]
}
}
Details below.
How hooks actually work
Hooks live under the hooks key in settings.json — either user-global at ~/.claude/settings.json or per-project at .claude/settings.json. Each entry is keyed by an event: PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, Notification, SessionStart, PreCompact.
For the tool events you give a matcher — a regex tested against the tool name (Bash, Edit, Write, Edit|Write) — and a list of hooks, each { "type": "command", "command": "..." }. The command runs in your shell.
Claude passes the event payload as JSON on stdin: tool_name, tool_input, session_id, and more. You parse it with jq. The hook talks back through its exit code:
- exit
0— allow / success. - exit
2— block. ForPreToolUsethe tool is cancelled and stderr is fed back to Claude as the reason, so it can adjust. - any other non-zero — non-blocking error, surfaced to you but the run continues.
That exit-code contract is the whole game. Everything below is just three small scripts wired to it. Same instinct as my PowerShell scripts for Claude — let the model drive, keep the guardrails in shell.
Auto-format on every edit
The one I'd never give up. Every time Claude touches a file via Edit or Write, format it.
~/.claude/hooks/format.sh:
#!/usr/bin/env bash
file=$(jq -r '.tool_input.file_path // empty')
[ -z "$file" ] && exit 0
[ -f "$file" ] || exit 0
case "$file" in
*.go) gofmt -w "$file" ;;
*.js|*.ts|*.tsx|*.jsx|*.json|*.md|*.css) npx prettier --write "$file" ;;
*.py) ruff format "$file" ;;
*.sh) shfmt -w "$file" ;;
esac
exit 0
The file path comes straight off tool_input.file_path. I match on extension, run the right formatter, exit 0. Claude sees the formatted result on its next read, so its mental model stays in sync — no drift between what it wrote and what's on disk.
The exception: prettier on a huge JSON file adds a second per edit. In repos where that bites, I scope the matcher to a project .claude/settings.json and drop *.json from the case.
Block dangerous Bash before it runs
PreToolUse on Bash is a veto. I inspect the command string and exit 2 on anything I never want executed unattended.
~/.claude/hooks/guard-bash.sh:
#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // empty')
deny() { echo "BLOCKED: $1" >&2; exit 2; }
# nuke-the-disk variants
echo "$cmd" | grep -Eq 'rm[[:space:]]+-[a-z]*r[a-z]*f?[[:space:]]+(/|~|\$HOME)([[:space:]]|$)' \
&& deny "recursive force-delete of a root/home path"
# force-push to main
echo "$cmd" | grep -Eq 'git[[:space:]]+push[[:space:]].*--force.*\b(main|master)\b' \
&& deny "force-push to a protected branch"
# piping the web straight into a shell
echo "$cmd" | grep -Eq '(curl|wget)[[:space:]].*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh)' \
&& deny "curl|wget piped into a shell"
exit 0
When this fires, the tool call never happens and the BLOCKED: ... line on stderr goes back to Claude as feedback — it reads the reason and usually proposes a safer command on its own. The block is real, not advisory.
The exception: this is a seatbelt, not a security boundary. A determined model can phrase around a regex, and I run Claude in a contained environment regardless. The hook stops accidents — a stray rm -rf assembled from a bad variable — not an adversary. Don't let it talk you out of permission scoping or sandboxing.
Tell me when it's done
Claude finishes and goes quiet. The Stop hook turns that into a desktop ping plus a log line, so I can context-switch away during a long run.
~/.claude/hooks/notify.sh:
#!/usr/bin/env bash
read -r payload
session=$(echo "$payload" | jq -r '.session_id // "?"')
ts=$(date '+%F %T')
echo "$ts stop session=$session" >> ~/.claude/run.log
# Linux desktop
command -v notify-send >/dev/null && notify-send "Claude Code" "Run finished"
# macOS
command -v osascript >/dev/null && \
osascript -e 'display notification "Run finished" with title "Claude Code"'
exit 0
Stop doesn't carry a tool_input — I just grab session_id for the log and fire the notification. The append-only run.log is handy on its own: a flat record of every session boundary I can tail later. Same SubagentStop event exists if you want a ping when a parallel subagent wraps up instead of the whole run.
Wiring it up
mkdir -p ~/.claude/hooks
chmod +x ~/.claude/hooks/*.sh
Edit settings.json, then run /hooks inside Claude Code to confirm everything registered. Changes to the file take effect on the next session — restart if a hook isn't firing.
Keep the scripts in their own files, not inline in the JSON. Inline shell inside a JSON string is a quoting nightmare, and a real file is something you can run by hand to debug. This is the same plumbing I leaned on building a local MCP server for sysadmin tools — small shell scripts, well-defined I/O contract, easy to test in isolation.
Gotchas
jqmust be on PATH. The hook shell isn't your interactive login shell — it may not source your profile. Use absolute paths or set PATH at the top of the script.- Exit 2 only blocks on
PreToolUse(and a couple of others). APostToolUsehook exiting 2 won't un-run the tool — the action already happened. Validate before, format after. - Quote
file_path. Paths with spaces (hello, Windows) break unquoted$file. Always"$file". - Hooks run on every match — keep them fast. A slow formatter on every
Editmakes the whole session feel sluggish. Scope the matcher tightly. - A non-2 non-zero exit is noise, not a stop. Forget to
exit 0at the end and a stray status leaks an error into your transcript. End every hook withexit 0. - User vs project settings both load. A global format hook plus a project one means the file gets formatted twice. Pick a layer per concern.
jq -r '... // empty'— without the// emptyfallback you get the literal stringnullwhen a field is missing, and your[ -z ]checks pass throughnullpaths. Guard every field.
Three scripts, one config block. The formatter alone paid for the setup in a day; the Bash guard is the one that lets me actually walk away.