Hooks in Claude Code — how I enforce discipline on the agent
Hooks are a small piece of config that turns Claude Code into a tool with real guardrails. Showing my setups: blocking destructive commands, enforcing commit policy, telemetry to Telegram.

Claude Code with hooks is not the same Claude Code. Hooks are scripts that run on specific events, before tool use, after, on session end, on every prompt. They aren't a framework. They're an integration point that separates "agent in a terminal" from "agent with discipline."
After three months I have ~10 active hooks. Here are the four with the highest payoff.
Hook #1: blocking destructive commands
PreToolUse on the Bash tool. If the command matches certain patterns, refuse.
#!/usr/bin/env bash
# .claude/hooks/block-destructive.sh
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // ""')
forbidden=(
"rm -rf /"
"git push --force"
"DROP DATABASE"
":(){ :|:& };:"
)
for pattern in "${forbidden[@]}"; do
if [[ "$cmd" == *"$pattern"* ]]; then
echo '{"decision":"block","reason":"Destructive command blocked"}'
exit 0
fi
done
echo '{"decision":"approve"}'Tiny entry in settings:
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": ".claude/hooks/block-destructive.sh" }]
}]
}
}What this gives you: the agent can try, the hook blocks. I don't rely on "the model will never run rm -rf." I rely on "if it tried, the hook would stop it."
Hook #2: commit policy — always, no questions
I have a preference: after every code change, Claude commits. Doesn't ask, doesn't wait. This is in my memory, but memory is a soft constraint. A hook is a hard one.
A Stop hook that checks for uncommitted changes and reminds:
#!/usr/bin/env bash
# .claude/hooks/commit-reminder.sh
cd "$(jq -r '.cwd' <<< "$(cat)")" || exit 0
if git diff --quiet && git diff --cached --quiet; then
exit 0 # clean
fi
cat <<EOF
{
"decision": "block",
"reason": "Uncommitted changes. Commit before ending the session."
}
EOFEffect: if the agent finishes with a dirty tree, it gets feedback "go back and commit." This eliminates 90% of cases where I find uncommitted changes after a session.
Hook #3: telemetry to Telegram
Every tool use is logged as an event. Most interesting to me: how many tool calls per session, distribution, time spent.
# .claude/hooks/telemetry.sh
event=$(cat)
tool=$(jq -r '.tool_name' <<< "$event")
when=$(date -Iseconds)
echo "$when $tool" >> ~/logs/claude-tools.log
# Every 100 calls — ping Telegram
count=$(wc -l < ~/logs/claude-tools.log)
if (( count % 100 == 0 )); then
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
-d "chat_id=${ADMIN_CHAT}" \
-d "text=Claude crossed ${count} tool calls today 🔧"
fiWhy bother? Before, I had no intuition for how much the agent actually executes. Now I see: a typical productive session is ~80 calls, a "model in a loop" session is 200+. I can react.
Hook #4: pre-push validation
PreToolUse on bash with a git push matcher. Checks:
- branch isn't
mainon force push - tests pass locally
- no
.env*files staged
#!/usr/bin/env bash
input=$(cat)
cmd=$(jq -r '.tool_input.command' <<< "$input")
# Force push to main = ALWAYS block
if [[ "$cmd" == *"push --force"* && "$cmd" == *"main"* ]]; then
echo '{"decision":"block","reason":"Force push to main is forbidden"}'
exit 0
fi
# Secrets staged
if git diff --cached --name-only 2>/dev/null | grep -qE '\.env'; then
echo '{"decision":"block","reason":"Staged .env file detected"}'
exit 0
fi
echo '{"decision":"approve"}'The hook doesn't test everything. It tests classic mistakes that have cost me. One hook, three rules, each worth its line cost.
What I do NOT put in hooks
Three things I learned not to push into them:
1. Business logic. A hook is a filter or telemetry. If I'm writing if/else with five business conditions, that's a sign it should be a separate script the agent calls deliberately.
2. Anything with high latency. A hook blocks the tool. If a hook takes 5 seconds, the agent waits. My rule: hook < 200ms. Bigger checks (e.g. test suite before push) run as a recommendation, not a block.
3. Rules I change weekly. Every hook is a small contract with the agent. Changed often, we both get confused. Memory + CLAUDE.md are better for soft preferences.
Setup in a nutshell
.claude/
├── settings.json # hook registry
└── hooks/
├── block-destructive.sh
├── commit-reminder.sh
├── telemetry.sh
└── validate-push.shEach hook is ~20 lines of bash + jq. No framework. No build step. That's what I like about Claude Code: hooks are bash scripts, not application code.
If you use Claude Code and haven't played with hooks, start with block-destructive.sh. Smallest effort, biggest return. As I tell the agent: "sharp tools, soft hands."