Most developers use Claude Code interactively — send a task, review the output, move on. Hooks unlock a different mode: deterministic behavior enforcement that runs regardless of what Claude decides to do. Block a class of commands. Log every file write to an audit trail. Trigger a notification when a long task finishes. Run your linter automatically after every code edit.
Hooks are shell scripts (or any executable) that Claude Code calls at specific lifecycle events. They run outside the model — no tokens, no latency, no prompt needed. The model cannot override them.
A hook is a shell command wired to a Claude Code lifecycle event. When the event fires, Claude Code runs your command, inspects the exit code, and proceeds accordingly.
Exit code behavior:
0 — success; allow the operation to proceed2 — blocking error; deny the operation, report reason to ClaudeYour hook can also write JSON to stdout to communicate back to Claude — providing additional context, modifying inputs, or setting a permission decision. This makes hooks composable with Claude's reasoning, not just blunt gate-checks.
Hook configuration lives in settings.json at one of three scopes:
| File | Scope | Committed? |
|---|---|---|
~/.claude/settings.json | All projects (user-level) | No |
.claude/settings.json | This project (shared) | Yes — team hooks go here |
.claude/settings.local.json | This project (local only) | No — gitignored |
User-level hooks apply globally. Project-level hooks let teams enforce consistent behavior — everyone on the project gets the same pre-commit check, the same audit log, the same blocked commands — automatically, from the first checkout.
The structure inside settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/your/hook.sh"
}
]
}
]
}
}
The matcher field filters which tool triggers the hook. Use "Bash" to catch shell commands, "Write" to catch file writes, "*" to match all tools.
The events that matter most for real workflows:
| Event | Fires when | Can block? |
|---|---|---|
PreToolUse | Before any tool runs | Yes (exit 2) |
PostToolUse | After any tool completes | No |
SessionStart | Session begins | No |
Stop | Claude stops (task done or interrupted) | No |
UserPromptSubmit | User sends a message | Yes |
SubagentStart | Sub-agent spawned | No |
SubagentStop | Sub-agent completes | No |
FileChanged | File written or modified | No |
PreCompact | Before context compaction | No |
PreToolUse is where most safety guardrails live. PostToolUse is where audit logging and automatic follow-up actions go. Stop is useful for notifications and handoff files. SessionStart lets you inject dynamic context that doesn't belong in a static CLAUDE.md.
When Claude Code fires a hook, it passes context as environment variables and/or stdin JSON. For PreToolUse on Bash, your script receives the command that Claude is about to run. You can inspect it, reject it, or pass it through.
Example: Claude Code calls your hook with something like:
{
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/build",
"description": "Clean build directory"
}
}
Your hook reads this from stdin (or env), checks the command, and exits 0 or 2.
The most common hook: refuse any rm -rf that Claude tries to run without an explicit path you approve.
#!/usr/bin/env bash
# ~/.claude/hooks/block-destructive.sh
set -euo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
if echo "$COMMAND" | grep -qE 'rm\s+-rf\s+(/|~|\$HOME|\./)'; then
echo '{"decision":"block","reason":"Destructive rm -rf on root/home path blocked by safety hook."}'
exit 2
fi
exit 0
Wire it into ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/block-destructive.sh"
}
]
}
]
}
}
Claude gets a clear reason for the block and can propose an alternative. You don't have to monitor every command — the hook does it.
For compliance-sensitive projects or debugging, log every file Claude writes — path, timestamp, size — to a local audit file.
#!/usr/bin/env bash
# .claude/hooks/audit-writes.sh
set -euo pipefail
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null || echo "")
TOOL=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_name',''))" 2>/dev/null || echo "")
if [ -n "$FILE" ]; then
SIZE=$(wc -c < "$FILE" 2>/dev/null || echo 0)
echo "$(date -Iseconds) | $TOOL | $FILE | ${SIZE}B" >> "${CLAUDE_PROJECT_DIR}/.claude/audit.log"
fi
exit 0
The CLAUDE_PROJECT_DIR environment variable is set by Claude Code to the project root — use it instead of hardcoding paths. The hook exits 0 (PostToolUse hooks can't block anyway), but the audit trail accumulates silently.
Long tasks run while you switch focus. A Stop hook fires when Claude is done — you can send a desktop notification, write a handoff file, or ping a webhook.
#!/usr/bin/env bash
# ~/.claude/hooks/notify-done.sh
# macOS notification
osascript -e 'display notification "Claude Code task complete" with title "Claude Code" sound name "Ping"'
# Write handoff file
echo "$(date -Iseconds): Session complete" >> ~/.foundry/runs/handoff-latest.md
exit 0
Wire to the Stop event:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/notify-done.sh"
}
]
}
]
}
}
Every time Claude writes to a .py or .ts file, run the linter and surface failures immediately — before Claude moves on to the next file.
#!/usr/bin/env bash
# .claude/hooks/lint-on-write.sh
set -euo pipefail
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null || echo "")
case "$FILE" in
*.py)
ruff check "$FILE" --quiet || {
echo "{\"additionalContext\": \"ruff found issues in $FILE — fix before continuing\"}"
exit 2
}
;;
*.ts|*.tsx)
npx eslint "$FILE" --quiet || {
echo "{\"additionalContext\": \"ESLint found issues in $FILE — fix before continuing\"}"
exit 2
}
;;
esac
exit 0
This hook uses additionalContext in the JSON output to pass the linter message back to Claude. Claude reads this and incorporates it into its next response — it knows what failed and why, without you having to re-explain.
Your hook can write JSON to stdout to communicate with Claude beyond a bare exit code:
| Field | Use |
|---|---|
decision | "allow" or "block" — explicit permission decision |
reason | Human-readable reason shown to Claude when blocking |
additionalContext | Extra info passed to Claude's context (non-blocking) |
updatedInput | Modified version of the tool input (PreToolUse only) |
Using additionalContext turns your hooks from blunt blockers into context injectors — the hook can say "this file was last modified by the deploy pipeline" or "this test suite takes 4 minutes" and Claude reasons with that information.
Hooks in .claude/settings.json are committed to source control. Every developer who checks out the repo gets the same hook enforcement automatically — no per-developer setup required.
Useful patterns for teams:
migrations/ that don't match your timestamp + description formatThe .claude/settings.local.json file (gitignored) is for hooks that shouldn't be shared — personal notification preferences, local-only paths, developer-specific secrets for hook scripts.
Run /hooks inside any Claude Code session to see all hooks currently active for that session — which events are wired, which scripts run, and at which scope (user vs. project). This is the fastest way to debug a hook that isn't firing or a permission decision you don't understand.
You can also check the config directly:
# User-level hooks
cat ~/.claude/settings.json | python3 -m json.tool | grep -A20 '"hooks"'
# Project-level hooks
cat .claude/settings.json 2>/dev/null | python3 -m json.tool | grep -A20 '"hooks"'
Hooks run per-session and don't share state between sessions. A hook that writes to a temp file in /tmp/ won't find that file in the next session. For cross-session memory — tracking what was done in prior runs, persisting decisions — you need to write to a stable path and read from it explicitly.
Hooks also can't modify Claude's base behavior: they operate on specific tool events, not on Claude's planning or reasoning. You can't hook into "Claude is about to make a bad architectural decision" — you can only hook into the discrete tool calls that result from that decision.
For teams using hooks in CI or automated pipelines, keep hook scripts fast. A hook that takes 10 seconds on every Bash call will make Claude Code unusable for rapid iteration. Cache expensive checks where you can; fail fast where you can't.
One pattern that doesn't work the way developers expect: using a Stop hook to "save state" that a SessionStart hook restores. The Stop hook fires reliably, but the context it saves is only useful if your SessionStart hook loads it and injects it into Claude's initial prompt — which requires the hook to produce output in the right format.
Claude Code doesn't have native cross-session memory. Each session starts fresh. Hooks can paper over this at the edges — injecting a handoff file at SessionStart, writing a summary at Stop — but the session boundary problem remains. Every new session, the model has no memory of what it did last time unless you explicitly hand that context back in.
Hooks help at the edges, but they don't solve the fundamental issue: Claude Code loses all context at session end. Beyond Your Ability (BYA) gives Claude persistent project memory across sessions — no handoff files, no re-explaining your stack every morning.
Learn how BYA works →