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. For PreToolUse the 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

  • jq must 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). A PostToolUse hook 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 Edit makes the whole session feel sluggish. Scope the matcher tightly.
  • A non-2 non-zero exit is noise, not a stop. Forget to exit 0 at the end and a stray status leaks an error into your transcript. End every hook with exit 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 // empty fallback you get the literal string null when a field is missing, and your [ -z ] checks pass through null paths. 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.


Related posts