Claude Code hook recipes: six configs worth stealing
Beyond the finish notification: a permission-prompt alert, a turn journal, a guardrail for dangerous commands, auto-formatting after edits, a tests-must-pass gate, and forwarding to a local daemon. Complete configs, with the caveats that keep them from backfiring.
The first hook everyone writes is a finish notification, and we covered that one already. But the hook system is a general automation surface: it can inspect every tool call, veto them, react to every turn, and feed external systems. This post is six configs we either run ourselves or stole the shape of, each complete enough to paste.
Two rules apply to all of them, learned the hard way. Hooks run in the agent's critical path, so anything slow gets backgrounded with & and anything network-shaped gets a timeout. And a notification-style hook always exits 0, because no bell is worth failing a turn over. The exceptions below are the hooks whose entire job is to block, and they are explicit about it.
All configs go in ~/.claude/settings.json (or a project's .claude/settings.json for per-repo hooks).
1. Alert only when Claude is blocked on you
A finished turn is nice to know about. A turn that is stuck waiting for your approval is the one silently burning time. The Notification event fires with a notification_type, and you can match on it:
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude is waiting for approval\" with title \"Claude Code\"' &"
}
]
}
]
}
}
A blocked agent looks identical to a working one from across the room. This is the single highest-value alert in the system, and most setups only wire Stop.
2. A turn journal in one line
Append one JSON line per event and you have personal telemetry: which projects you run agents in, how often, at what hours. Wire it to whichever events you care about:
#!/bin/sh
cat | /usr/bin/jq -c \
'{t: now|floor, project: (.cwd|split("/")|last), event: .hook_event_name}' \
>> ~/.claude/turn-log.jsonl 2>/dev/null &
exit 0
Attach it to UserPromptSubmit and Stop, and the gap between paired lines is your wait time. We built a whole product on top of that number, but the jsonl file alone will tell you things you did not know about your own usage.
3. A speed bump for dangerous commands
PreToolUse can veto or question any tool call before it runs. This one makes Claude ask for confirmation on command patterns you consider radioactive, even in sessions where you have loosened permissions:
#!/bin/bash
input=$(cat)
cmd=$(jq -r '.tool_input.command // empty' <<<"$input")
case "$cmd" in
*"rm -rf"*|*"push --force"*|*"reset --hard"*)
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: "Hook flagged this as destructive. Confirm."}}'
;;
esac
exit 0
Register it with "matcher": "Bash" under PreToolUse. Note the decision is ask, not deny: a blanket deny teaches the agent to find workarounds, while ask routes the judgment call to you. String matching is crude, and that is fine. This is a speed bump, not a sandbox, and pretending a regex is a security boundary is how regexes end up in incident reports.
4. Auto-format what Claude edits
PostToolUse with an Edit|Write matcher sees every file Claude touches, with the path in tool_input.file_path:
#!/bin/sh
f=$(cat | jq -r '.tool_input.file_path // empty')
case "$f" in
*.ts|*.tsx|*.js|*.css) npx prettier --write "$f" >/dev/null 2>&1 & ;;
*.rs) rustfmt "$f" >/dev/null 2>&1 & ;;
esac
exit 0
Caveat worth knowing: the file changes under Claude after it wrote it, so a subsequent exact-match edit can miss. Pure formatters are usually harmless in practice, but if your sessions do long multi-edit sequences on one file, prefer running the formatter once from the tests-gate below instead of per edit.
5. Tests must pass before the turn ends
Stop is the one event where exit code 2 means "not so fast": stderr goes back to Claude as instructions and the turn continues. That turns a hook into a quality gate:
#!/bin/sh
input=$(cat)
[ "$(printf '%s' "$input" | jq -r '.stop_hook_active')" = "true" ] && exit 0
if ! npm test --silent >/dev/null 2>&1; then
echo "The test suite is failing. Run npm test and fix the failures before finishing." >&2
exit 2
fi
exit 0
The stop_hook_active check is not optional. It is true when the turn is already a continuation your stop hook forced, and skipping the check builds a turn that can never end. We covered the flag in the payload reference. Also scope this to a project's .claude/settings.json and a fast test suite; a global stop hook that runs ten minutes of tests after every turn is a punishment you will inflict on yourself within the hour.
6. Forward everything to something that thinks
The ceiling on shell one-liners is low: no state, no session tracking, no UI. The pattern with headroom is a dumb hook and a smart receiver, where the hook does nothing but forward the payload to a local daemon:
#!/bin/sh
curl -s -m 1 -X POST -H 'Content-Type: application/json' \
--data "$(cat)" "http://127.0.0.1:4242/wait/start?ppid=$PPID" \
>/dev/null 2>&1 &
exit 0
The daemon can then correlate sessions, debounce notifications, track state across turns, and draw actual UI, none of which a hook script can. This is Unwait's entire sensor architecture: three hooks this thin, one daemon that does everything else. If you build your own receiver, two prior posts apply: localhost is not a security boundary covers authenticating it, and the SSH post covers what breaks when the agent is remote.
Composing them
These stack cleanly because they attach to different events: the journal watches, the speed bump and tests gate enforce, the alert interrupts, the daemon absorbs everything for later. Start with the permission alert and the journal, since they are pure upside. Add enforcement hooks one at a time, in project scope first, and promote them to global config only after a week of not hating them. The full event list and payload schemas are in the official hooks reference.