this repo has 9 hooks you can use. here's how they work and how to build your own.
hooks are the difference between "claude code does what i want" and "claude code does whatever it feels like." CLAUDE.md gives guidance. hooks give enforcement. one is a suggestion, the other is a wall.
hooks are shell scripts (or LLM prompts) that fire on claude code lifecycle events. they intercept, validate, block, or extend what claude does -- from session start to tool execution to context compaction to shutdown.
think of them as git hooks, but for your AI coding agent.
a hook:
- receives JSON on stdin -- session ID, tool name, tool input, transcript path
- runs your logic -- inspect input, check conditions, log, call APIs
- returns via exit code:
exit 0-- allow (proceed normally)exit 2-- block (stop this action, stderr shown to claude)- anything else -- non-blocking error (logged, execution continues)
| hook | event | what it does |
|---|---|---|
| safety-guard | PreToolUse | blocks 6 categories of destructive bash commands |
| no-squash | PreToolUse | blocks squash merges -- preserves commit history |
| context-save | PreCompact | saves session state before context compression |
| panopticon | PostToolUse | logs every tool call to sqlite for later analysis |
| commit-nudge | PostToolUse | soft reminder after 8+ edits without a commit |
| md-lint-fix | PostToolUse | auto-runs markdownlint-fix on saved .md files |
| version-stamp | SessionEnd | updates "tested with" version stamps in changed files |
| stale-branch | SessionStart | warns about local branches with deleted remotes |
| notify | Notification | routes claude code alerts to macOS notifications |
hook fire frequency is driven by tool usage. from real session data:
| tool event | fires | what triggers hooks |
|---|---|---|
| Bash (10,153) | most hook-triggering | safety-guard, no-squash, commit-nudge all fire on Bash |
| Read (9,187) | panopticon logs these | panopticon tracks all read operations |
| Edit (5,010) | panopticon tracks | md-lint-fix fires on .md edits, commit-nudge counts edits |
| Write (1,696) | panopticon tracks | version-stamp checks written files at SessionEnd |
PreToolUse hooks (safety-guard, no-squash) fire on every Bash call -- 10K+ times across all sessions. that's why they need to be fast (< 50ms).
three categories of damage:
destructive commands -- safety-guard.sh blocks force-pushes to main, rm -rf /, DROP TABLE, chmod 777 on sensitive paths, and curl | bash remote execution. exit code 2 = hard block, no override.
bad merges -- no-squash.sh blocks --squash on any merge. one CLAUDE.md rule saying "don't squash" gets ignored eventually. a hook that exits 2 never does.
context loss -- context-save.sh fires on PreCompact and writes a handoff markdown before compression. without it, every /compact wipes your plan. with it, claude reads the handoff and picks up where it left off.
use CLAUDE.md when you want to guide behavior -- coding style, naming conventions, preferred patterns. claude reads it, usually follows it, occasionally forgets.
use hooks when you want to enforce behavior -- things that must never happen, things that must always happen. hooks don't forget. they don't get creative. they run every time.
rule of thumb: if you'd be angry when it's violated, make it a hook. if you'd be mildly annoyed, put it in CLAUDE.md.
CLAUDE.md: "prefer conventional commits" -- guidance
hook: block force-push to main -- enforcement
hooks live in JSON settings files at three levels:
| location | scope | shareable |
|---|---|---|
~/.claude/settings.json |
all your projects | no (local) |
.claude/settings.json |
single project | yes (commit it) |
.claude/settings.local.json |
single project | no (gitignored) |
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/your-script.sh"
}
]
}
]
}
}the matcher field is a regex that filters when hooks fire:
| events | what matcher filters | examples |
|---|---|---|
| PreToolUse, PostToolUse | tool name | Bash, Edit|Write, mcp__memory__.* |
| SessionStart | how session started | startup, resume, clear, compact |
| SessionEnd | why session ended | clear, logout, prompt_input_exit |
| Notification | notification type | permission_prompt, idle_prompt |
| SubagentStart, SubagentStop | agent type | Bash, Explore, Plan |
| PreCompact | what triggered compaction | manual, auto |
| UserPromptSubmit, Stop | no matcher support | always fires |
use "*", "", or omit matcher entirely to match all.
| type | description | default timeout |
|---|---|---|
command |
shell command, receives JSON on stdin | 600s |
prompt |
single-turn LLM evaluation | 30s |
agent |
subagent with Read, Grep, Glob access | 60s |
http |
POST JSON to a URL | 30s |
prompt and agent hooks only work on: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, Stop, SubagentStop, TaskCompleted, UserPromptSubmit. everything else is command only.
SessionStart -- fires on session begin/resume/clear/compact. stdout becomes conversation context. use for loading git status, TODOs, project state. can set env vars via CLAUDE_ENV_FILE.
UserPromptSubmit -- fires before claude processes each prompt. stdout becomes context. can block with exit 2.
PreToolUse -- fires before every tool call. this is your safety layer. can block (exit 2), allow, or modify tool inputs via updatedInput JSON.
PermissionRequest -- fires only when user would see a permission dialog. use for auto-approving safe commands (npm test) or auto-denying risky ones.
PostToolUse -- fires after successful tool execution. use for formatting, logging, linting. can't block (tool already ran).
PreCompact -- fires before context compression. use for saving session state.
Stop -- fires when claude finishes responding. use for post-response validation.
Notification -- fires on permission prompts, idle alerts. use for custom notification routing.
SessionEnd -- fires on session close. use for cleanup, version stamps.
official hooks docs -- full event reference, input schemas, advanced patterns
every hook script in this repo follows the same pattern:
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
# extract what you need from the JSON
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# your logic here
if echo "$COMMAND" | grep -q "git push.*--force.*main"; then
echo "blocked: force-push to main is not allowed" >&2
exit 2 # hard block
fi
exit 0 # allowkey patterns:
- always
set -euo pipefailat the top - read all of stdin into a variable (hooks get JSON on stdin)
- use
jqto extract fields exit 2to block,exit 0to allow- stderr on
exit 2is shown to claude as the reason
instead of just exit codes, you can print JSON to stdout for finer control:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Force-push to main is not allowed"
}
}three decisions: "allow" (bypass permission system), "deny" (block), "ask" (prompt user).
you can also modify tool inputs before execution:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"command": "npm test -- --coverage"
}
}
}hooks should be:
- invisible when things go right -- you shouldn't notice them firing
- loud when things go wrong -- exit 2 with a clear reason
- cheap to run -- bash + jq, not python + imports
- standalone -- each hook is one script, no shared libraries
- hooks directory -- all 11 hook scripts with full source
- official hooks docs -- complete reference