Blog
ENPL

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.

·4 min read
Hooks in Claude Code — how I enforce discipline on the agent

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."
}
EOF

Effect: 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 🔧"
fi

Why 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 main on 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.sh

Each 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."