I trusted Claude Code to run shell commands on my machine. Then it tried to rm -rf a build directory I needed.
That was the moment I stopped relying on permission prompts and started writing hooks -- deterministic shell commands that fire at specific points in Claude Code's lifecycle. No LLM judgment. No "are you sure?" dialogs. Just code that blocks what I don't want and enforces what I do.
Here are 5 hook patterns that replaced hours of manual oversight with a few lines of Bash.
What Are Claude Code Hooks?
Hooks are user-defined shell commands that execute automatically at specific lifecycle points inside Claude Code. They fire at 18 distinct lifecycle events -- before a tool runs, after it succeeds, when a session starts, when Claude stops responding, and more.
The key word is deterministic. A hook runs because an event happened, not because the LLM decided to run it. You define them in ~/.claude/settings.json (global) or .claude/settings.json (per-project), and they follow this structure:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/your-script.sh"
}
]
}
]
}
}
Three layers: event (PreToolUse), matcher ("Bash" filters to only Bash tool calls), and handler (the shell command that runs). Your script receives JSON on stdin with the event context. It controls the outcome through exit codes: exit 0 means proceed, exit 2 means block.
Claude Code supports four hook types as of March 2026: command (shell), http (POST to a URL), prompt (single LLM evaluation), and agent (multi-turn subagent with tool access). This article focuses on command hooks -- the ones you control completely.
1. Block Destructive Shell Commands
The hook that would have saved me from the rm -rf incident. This PreToolUse hook intercepts every Bash command before it runs. If the command matches a destructive pattern, it exits with code 2 and sends the reason to stderr -- Claude receives it as feedback and adjusts.
Save this as .claude/hooks/block-destructive.sh:
#!/bin/bash
# block-destructive.sh -- Block dangerous commands before execution
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
# Define destructive patterns
BLOCKED_PATTERNS=(
"rm -rf /"
"rm -rf ~"
"rm -rf *"
"DROP TABLE"
"DROP DATABASE"
"mkfs"
"> /dev/sda"
)
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qi "$pattern"; then
echo "Blocked: command matches destructive pattern '$pattern'" >&2
exit 2 # Exit 2 = block the tool call
fi
done
exit 0 # Exit 0 = allow it
Register it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-destructive.sh"
}
]
}
]
}
}
The $CLAUDE_PROJECT_DIR variable resolves to your project root, so the path works regardless of the current working directory when the hook fires.
Why this matters: Claude Code's permission system asks before running commands. But permission fatigue is real -- after the 50th "Allow?" prompt, you start clicking yes without reading. Hooks remove the human bottleneck. Dangerous patterns get blocked before the prompt ever appears.
2. Auto-Format Every File Edit
Every time Claude writes or edits a file, this hook runs Prettier on the changed file. No more "fix the formatting" follow-up prompts.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null; exit 0"
}
]
}
]
}
}
This uses PostToolUse with a matcher of "Edit|Write" -- the matcher is a regex, so the pipe means "match either tool." The hook reads the file_path from the JSON input with jq, passes it to Prettier, and always exits 0 so it never blocks Claude's flow.
The 2>/dev/null suppresses Prettier's output for files it can't parse (like Markdown without a config). The trailing exit 0 ensures the hook never accidentally blocks with a non-zero Prettier exit code.
Swap Prettier for your tool: Replace npx prettier --write with black (Python), gofmt -w (Go), rustfmt (Rust), or any formatter that accepts a file path.
3. Protect Sensitive Files From Edits
Some files should never be touched by an AI agent: .env files with secrets, lock files that break installs, or configuration files that require human review.
Save as .claude/hooks/protect-files.sh:
#!/bin/bash
# protect-files.sh -- Block edits to sensitive files
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Exit early if no file path (shouldn't happen, but safe)
if [ -z "$FILE_PATH" ]; then
exit 0
fi
PROTECTED_PATTERNS=(
".env"
"package-lock.json"
"yarn.lock"
".git/"
"credentials"
"secrets"
)
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: '$FILE_PATH' matches protected pattern '$pattern'. Edit this file manually." >&2
exit 2
fi
done
exit 0
Register it as a PreToolUse hook with a "Edit|Write" matcher, the same way as pattern 1 but filtered to file-editing tools instead of Bash.
What Claude sees: When the hook blocks, stderr becomes Claude's error message. Claude reads "Blocked: '.env' matches protected pattern" and pivots -- it might ask you to make the change manually or suggest a different approach. The feedback loop is built in.
4. Re-Inject Context After Compaction
This one solved a problem I didn't know I had until I lost 30 minutes to it.
Claude Code has a context window. When the conversation fills it, Claude compacts the history into a summary to free space. That summary loses details -- your project conventions, what you were working on, which tools to prefer.
A SessionStart hook with a "compact" matcher fires after every compaction and re-injects whatever context you need. Anything your script writes to stdout becomes context that Claude can see.
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Project rules: Use pnpm, not npm. Run pnpm test before committing. Current branch: feature/auth-refactor. Key files: src/auth/provider.ts, src/middleware/session.ts'"
}
]
}
]
}
}
For dynamic context, replace echo with a script that reads your project state:
#!/bin/bash
# reinject-context.sh
echo "=== Project Context ==="
echo "Last 5 commits:"
git log --oneline -5 2>/dev/null
echo ""
echo "Modified files:"
git diff --name-only HEAD 2>/dev/null
echo ""
echo "Active branch: $(git branch --show-current 2>/dev/null)"
echo "Node version: $(node --version 2>/dev/null)"
echo "Test status: $(pnpm test --silent 2>&1 | tail -1)"
Every compaction now gives Claude a fresh snapshot of where you are instead of a lossy summary.
5. Log Every Command for Audit
When an AI agent runs shell commands on your machine, you want a record. This PostToolUse hook appends every Bash command to a log file with a timestamp.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command\"); echo \"[$(date -Iseconds)] $CMD\" >> ~/.claude/command-audit.log'"
}
]
}
]
}
}
After a session, ~/.claude/command-audit.log contains:
[2026-03-10T08:30:00+00:00] npm test
[2026-03-10T08:30:15+00:00] git diff --stat
[2026-03-10T08:31:02+00:00] npx prettier --write src/utils.ts
This pairs well with pattern 1. The block hook prevents damage in real time. The audit hook gives you a complete trail after the fact.
Scale it up: Swap the file append with curl to POST to a logging service, or pipe through jq to write structured JSON to a JSONL file for analysis.
The 18 Lifecycle Events
Claude Code hooks fire at 18 distinct lifecycle points. Here are the ones I use most:
| Event | When It Fires | Can Block? |
|---|---|---|
SessionStart |
Session begins or resumes | No |
UserPromptSubmit |
You submit a prompt | Yes |
PreToolUse |
Before a tool call executes | Yes |
PostToolUse |
After a tool call succeeds | No* |
Stop |
Claude finishes responding | Yes |
PreCompact |
Before context compaction | No |
SessionEnd |
Session terminates | No |
*PostToolUse can provide feedback to Claude with decision: "block", but the tool already ran.
The full list includes PermissionRequest, PostToolUseFailure, Notification, SubagentStart, SubagentStop, TeammateIdle, TaskCompleted, InstructionsLoaded, ConfigChange, WorktreeCreate, and WorktreeRemove. Each has specific matchers and input schemas documented at code.claude.com/docs/en/hooks.
Advanced: Prompt and Agent Hooks
Beyond shell commands, Claude Code supports two LLM-powered hook types.
Prompt hooks (type: "prompt") send the event data to a Claude model for single-turn evaluation. The model returns {"ok": true} or {"ok": false, "reason": "..."}. Use these when the decision requires judgment, not pattern matching:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check if all requested tasks are complete based on this context: $ARGUMENTS. Respond with {\"ok\": true} or {\"ok\": false, \"reason\": \"what remains\"}.",
"timeout": 30
}
]
}
]
}
}
Agent hooks (type: "agent") spawn a subagent with tool access -- it can read files, grep code, and run up to 50 turns before returning a decision. Use these when verification requires inspecting the actual codebase, not just the event data.
Both types default to a fast model for speed and cost. Override with the model field if you need more capable evaluation.
Getting Started
Start with the
/hooksmenu. Type/hooksinside Claude Code to interactively add hooks without editing JSON files.Pick one pattern. The destructive command blocker (Pattern 1) is the highest-value starting point. It takes 2 minutes and prevents real damage.
Test with
--debug. Runclaude --debugto see hook execution details: which hooks matched, exit codes, and output.Store project hooks in
.claude/settings.json. They travel with your repo. Team-wide enforcement without asking people to configure anything.Use
$CLAUDE_PROJECT_DIRfor paths. This environment variable always resolves to the project root, so your scripts work regardless of the working directory.
Hooks turn Claude Code from a tool you watch into a tool you trust. The 5 patterns above cover the most common automation needs. The 18 lifecycle events and 4 hook types give you the building blocks for anything else.
Follow @klement_gunndu for more AI productivity content. We're building in public.
Top comments (0)