Stop guessing what to hook. Here are six copy-paste scripts that solve real problems — and the missing fundamentals that make them work.
7 min read
22 hours ago
--
If you read Part 1, you know the why behind Claude Code Hooks: they turn “Claude will probably follow this rule” into “Claude has no choice but to follow this rule.”
Press enter or click to view image in full size
But the most common question after Part 1 was simple: “Okay — what do I actually write?”
This article answers that. But before the recipes, there are two things Part 1 skipped that you need to understand first — otherwise your scripts are just guessing.
The Complete Lifecycle: Every Trigger Point
Claude Code fires hooks at seven built-in events, grouped into three cadences:
Once per session
SessionStart— fires the moment you open Claude CodeSessionEnd— fires when the session closes
Once per turn
UserPromptSubmit— fires every time you hit EnterStop— fires when Claude signals it's finishedStopFailure— fires when Claude stops because something went wrong
Every tool call
PreToolUse— fires before Claude executes any toolPostToolUse— fires after any tool completes
The three cadences matter because they serve different purposes. Session-level hooks are for setup and teardown. Turn-level hooks are for quality gates and prompt enhancement. Tool-level hooks — PreToolUse and PostToolUse — are where most of the real control lives, because they fire the most often and give you the finest granularity.
The Missing Piece: What Claude Code Actually Sends Your Script
Part 1 explained that Claude Code sends event data to your script via stdin as JSON. But it never showed what that JSON looks like — which means writing a hook is a guessing game without it.
Here’s the real payload for a PreToolUse event on a Bash command:
{
"session_id": "abc-123",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm install express-validator"
}
}And for a file write (Write tool):
{
"session_id": "abc-123",
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "src/routes/users.ts",
"content": "..."
},
"tool_response": {
"success": true
}
}Your script reads this from stdin and exits with a number:
exit 0→ allow the action to proceedexit 2→ block it (your stderr message gets sent back to Claude as the reason)
There’s also a third option that Part 1 didn’t mention at all.
You can inject content back into Claude’s context via stdout.
If your script prints JSON to stdout on exit 0, Claude Code reads it and adds it to Claude's context window. This means hooks aren't just blockers — they're a way to silently feed Claude information at exactly the right moment.
{ "output": "Team rule: always use zod for validation, not joi." }Return that from a UserPromptSubmit hook and Claude reads it before processing your prompt — without you typing a word.
With those foundations in place, here are six recipes.
Recipe 1 — Block Writes to Sensitive Files
The problem: Claude helpfully edits .env files, private keys, or credentials it should never touch.
The hook: PreToolUse on Write and Edit.
# .claude/scripts/block-sensitive-files.py
import json, sys, re
payload = json.load(sys.stdin)
file_path = payload.get("tool_input", {}).get("file_path", "")BLOCKED = [r"\.env$", r"\.pem$", r"\.key$", r"secrets/", r"\.aws/credentials"]
for pattern in BLOCKED:
if re.search(pattern, file_path):
print(f"BLOCKED: writes to '{file_path}' are not allowed.", file=sys.stderr)
sys.exit(2)
sys.exit(0)
"PreToolUse": [{
"matcher": "Write|Edit",
"hooks": [{
"type": "command",
"command": "python .claude/scripts/block-sensitive-files.py"
}]
}]Press enter or click to view image in full size
Claude gets told exactly which file was blocked and why. It reroutes on its own — no intervention from you.
Recipe 2 — Auto Type-Check After Every File Write
The problem: Claude writes TypeScript that passes ESLint but breaks the compiler. You only find out after ten more file writes.
Get Narmadha’s stories in your inbox
Join Medium for free to get updates from this writer.
Remember me for faster sign in
The hook: PostToolUse on Write, running tsc --noEmit immediately after each file.
#!/bin/bash
# .claude/scripts/typecheck.sh
FILE=$(python3 -c "import sys,json; d=json.load(sys.stdin); print(d['tool_input'].get('file_path',''))")if [[ "$FILE" == *.ts || "$FILE" == *.tsx ]]; then
OUTPUT=$(npx tsc --noEmit 2>&1)
if [ $? -ne 0 ]; then
echo "$OUTPUT" >&2
echo "TypeScript errors found — fix before continuing." >&2
exit 2
fi
fi
exit 0
"PostToolUse": [{
"matcher": "Write",
"hooks": [{
"type": "command",
"command": "bash .claude/scripts/typecheck.sh"
}]
}]Press enter or click to view image in full size
Every .ts and .tsx file Claude writes gets type-checked immediately. No more discovering 15 compiler errors at the very end of a session.
Recipe 3 — Inject Team Context at Session Start
The problem: Every session, you want Claude to already know the current sprint goal, what files are off-limits, and which libraries your team prefers.
The hook: SessionStart with stdout injection — the output field gets added to Claude's context before you type your first prompt.
# .claude/scripts/inject-context.py
import json, sys
from datetime import datecontext = {
"output": f"""
=== TEAM CONTEXT ({date.today()}) ===
Sprint goal: Payments refactor — do not touch unrelated files
Off-limits: src/legacy/billing.py (migration in progress)
Preferred libraries: zod (not joi), axios (not fetch), dayjs (not moment)
Test command: npm run test:unit
===
"""
}
print(json.dumps(context))
sys.exit(0)
"SessionStart": [{
"hooks": [{
"type": "command",
"command": "python .claude/scripts/inject-context.py"
}]
}]Press enter or click to view image in full size
Claude is fully briefed before you type a word. No CLAUDE.md needed for team-wide rules that change sprint to sprint — just update this script.
Recipe 4 — Slack Notification When Claude Finishes
The problem: You kick off a long Claude Code session, walk away, and forget to check back.
The hook: Stop event posting to Slack's webhook.
# .claude/scripts/notify-slack.py
import json, sys, urllib.request, ospayload = json.load(sys.stdin)
session_id = payload.get("session_id", "unknown")
webhook_url = os.environ["SLACK_WEBHOOK_URL"]
message = {
"text": f" Claude Code session `{session_id}` has finished. Time to review."
}
req = urllib.request.Request(
webhook_url,
data=json.dumps(message).encode(),
headers={"Content-Type": "application/json"}
)
urllib.request.urlopen(req)
sys.exit(0)
"Stop": [{
"hooks": [{
"type": "command",
"command": "python .claude/scripts/notify-slack.py"
}]
}]Press enter or click to view image in full size
Swap the Slack webhook for any endpoint — Teams, Discord, PagerDuty, or your own internal system. The StopFailure event is also worth hooking here so you get notified differently when Claude stops because something went wrong.
Recipe 5 — Audit Log of Everything Claude Does
The problem: In a regulated environment or shared team project, you want a record of every tool Claude executed — what it tried, when, and whether it was allowed.
The hook: PostToolUse writing to a local .jsonl file.
# .claude/scripts/audit-log.py
import json, sys
from datetime import datetime, timezonepayload = json.load(sys.stdin)
log_entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"session_id": payload.get("session_id"),
"tool": payload.get("tool_name"),
"input": payload.get("tool_input"),
"success": payload.get("tool_response", {}).get("success")
}
with open(".claude/audit.jsonl", "a") as f:
f.write(json.dumps(log_entry) + "\n")
sys.exit(0)
"PostToolUse": [{
"hooks": [{
"type": "command",
"command": "python .claude/scripts/audit-log.py"
}]
}]Press enter or click to view image in full size
The .jsonl format means every line is a valid JSON object — pipe it into jq, load it into a database, or pull it into a dashboard. Add .claude/audit.jsonl to .gitignore and you have a local, private audit trail that costs nothing.
Recipe 6 — Policy Enforcement Without Writing Code
All five recipes above require a script. But there’s a handler type that requires nothing except plain English: prompt.
You write a condition in natural language. Claude evaluates it against the tool call and returns either ALLOW or BLOCK with a reason.
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "prompt",
"prompt": "Does this Bash command delete files, drop databases, reset migrations, or modify system configuration outside the project directory? If yes, respond BLOCK with a one-sentence reason. If no, respond ALLOW."
}]
}]Press enter or click to view image in full size
No Python. No bash. The trade-off is latency — a prompt hook is slower than a script because it makes an LLM call. But for nuanced policies that are hard to express in code, it's the right tool.
One Security Note Worth Making
Project-level hooks in .claude/settings.json get committed to git and run automatically when any teammate opens Claude Code. That's the feature — but it's also the risk.
A hook script runs with your shell’s full permissions. A buggy script in a cloned repo’s .claude/ folder can cause real damage. A malicious one in a third-party repo can do worse.
Two habits that protect you:
- Never open Claude Code in an unfamiliar cloned repo without reviewing
.claude/settings.jsonfirst. - Code-review hook scripts the same way you’d review any shell script going into your CI pipeline. They deserve the same scrutiny.
Press enter or click to view image in full size
Your Starter Project Layout
your-project/
├── .claude/
│ ├── settings.json ← hook configuration
│ ├── audit.jsonl ← add to .gitignore
│ └── scripts/
│ ├── block-sensitive-files.py
│ ├── typecheck.sh
│ ├── inject-context.py
│ ├── notify-slack.py
│ └── audit-log.py
├── CLAUDE.md
└── src/Commit everything except audit.jsonl. Your whole team gets the hooks the moment they pull.
What’s Next
Part 3 will go deeper into the agent handler — the most powerful and least understood hook type. Instead of running a script, it spawns a subagent with access to tools like Read, Grep, and Glob to deeply inspect your codebase before allowing an action.
Think of it as a code reviewer that runs before Claude writes a single line. That’s worth its own article.
Did any of these recipes solve a problem you’ve been dealing with? Drop it in the comments — I’d like to know what the community is building.
