Claude Code hooks are the feature most developers skip past. They sit in settings.json under a hooks key, they run shell commands in response to agent events, and they unlock a class of automation that subagents and memory cannot replace. I have been running hooks in production for a few months. I use them to notify Telegram when an agent stops, block bash commands that match risky patterns, and fire build scripts after file edits. This post documents the patterns that actually work.
What Claude Code Hooks Are
Hooks are shell commands that run automatically when Claude Code does something. That's it. No plugins, no extensions, no special runtime. You write a command, attach it to an event, and Claude executes it as part of its normal operation.
They live in ~/.claude/settings.json (global) or .claude/settings.json (project-level) under the hooks key. A hook entry looks like this:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npm run lint --silent"
}
]
}
]
}
}
The three hook events you'll actually use:
- PreToolUse — fires before Claude runs a tool. Return a non-zero exit code to block the action entirely.
- PostToolUse — fires after a tool completes. Use for side effects: linting, building, logging.
- Stop — fires when Claude's agent session ends. Good for notifications and cleanup.
There's also SubagentStop for when a spawned subagent finishes, and PreCompact for before context compaction runs.
The matcher field is a regex matched against the tool name. Write|Edit catches both file write and edit operations. Bash catches every shell command Claude runs. Leave matcher empty to catch everything.
Pattern 1: Telegram Notification on Agent Stop
The most useful hook I run. When Claude finishes a long task, I get a Telegram message instead of watching the terminal.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "curl -s -X POST 'https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage' -d chat_id=${TELEGRAM_CHAT_ID} -d text='Claude+stopped'"
}
]
}
]
}
}
I use a wrapper script that sends the full session summary, not just a ping. The hook passes context via environment variables — CLAUDE_STOP_HOOK_ACTIVE, tool name, tool input — so you can make the message more specific.
For longer sessions I pipe the stop reason into the message body. Claude includes a stop_reason in the hook payload. A session that stopped because it hit a blocker gets a different message than one that completed normally.
The practical result: I kick off a large refactor, switch to something else, and get notified on my phone when it's done. No terminal babysitting.
Pattern 2: Blocking Risky Bash Commands
PreToolUse with a non-zero exit code blocks Claude from running the tool. Combined with a matcher on Bash, this gives you a guard layer before any shell command executes.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/check-command.sh"
}
]
}
]
}
}
The check-command.sh script reads the command from stdin (Claude pipes the tool input as JSON) and checks it against a blocklist:
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('command',''))")
BLOCKED_PATTERNS=(
"git push --force"
"rm -rf /"
"DROP TABLE"
"git reset --hard"
)
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if echo "$CMD" | grep -qi "$pattern"; then
echo "Blocked: $pattern" >&2
exit 1
fi
done
exit 0
Exit 1 blocks the command. Claude sees the stderr output and knows why it was blocked. It can then ask for confirmation or try a safer alternative.
This is not a security boundary — it's a guard against accidental mistakes in long autonomous sessions. Claude has permissions to do most things; the hook catches the patterns I've decided should never run without me seeing them first.
I also use this for project-specific rules. One project has a hook that blocks npm install without a lockfile check. Another blocks any mutation to the production database connection string.
Pattern 3: Build Trigger After File Edits
When Claude edits TypeScript or config files, I want the build to run immediately. Not as a separate step I have to remember — as an automatic response to the edit.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/maybe-build.sh"
}
]
}
]
}
}
The maybe-build.sh script checks if the edited file is inside the src/ directory and if so runs npm run build --silent. It reads the edited path from the hook JSON payload on stdin.
The key detail: PostToolUse hooks run synchronously. Claude waits for the hook to complete before moving to the next step. If the build fails, Claude sees the output and can fix the issue in the same session — before it moves on and forgets what it just changed.
This pattern catches a class of bugs that normally surface in CI, hours after the edit. Type errors, missing imports, broken exports — they show up immediately instead of in a pull request review.
What Hooks Cannot Do
Hooks run shell commands. They do not modify Claude's behavior mid-session, inject new context, or override Claude's responses. They are side effects only.
Memory and preferences in CLAUDE.md affect how Claude thinks. Hooks affect what happens around Claude's actions. These are different layers — hooks are not a replacement for good prompting or project configuration.
Hooks also do not run if Claude is in a subagent spawned without the parent's settings. Project-level hooks in .claude/settings.json do apply to subagents running in that directory. Global hooks only apply to the top-level session.
The Settings File Structure
Full example of a working hooks configuration:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/check-command.sh" }]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/maybe-build.sh" }]
}
],
"Stop": [
{
"hooks": [{ "type": "command", "command": "~/.claude/hooks/notify-telegram.sh" }]
}
]
}
}
Hooks run in order within each event type. Multiple hooks on the same event all execute. For PreToolUse, the first non-zero exit code blocks the action — subsequent hooks in that event do not run.
FAQ
Where do hooks run — local machine or Claude's sandbox?
Hooks run on your local machine, in the same environment where Claude Code is running. They have access to your env vars, filesystem, and network. There is no sandboxing.
Can a hook pass data back to Claude?
Yes. Whatever your hook writes to stdout is captured and can influence Claude's next step. For PostToolUse hooks, stdout output becomes part of the tool result Claude sees. For PreToolUse, non-empty stderr is shown to Claude as the reason for a block.
Do hooks work in headless/cron sessions?
Yes. Hooks run wherever Claude Code runs. I use the Stop hook in my automated Paperclip agent sessions — it fires at the end of each heartbeat run and logs completion to a file.
What if the hook script doesn't exist?
Claude logs a warning and continues. A missing hook script does not block the session. This is intentional — hooks are additive, not required.
I share more automation patterns in my Build & Automate community on Skool — including the full Telegram notification setup and how I structure hook scripts across projects.
Published using Notipo — markdown editor with one-click WordPress publishing.




