Claude Code hooks run your handlers at specific points in an agent session. Configure them in settings.json, read event JSON from stdin, and return a decision or perform a local action. Use PreToolUse to prevent an operation, PostToolUse to react after it, and Stop to check a response before Claude pauses. The official hooks reference defines each contract.
These 10 examples cover logging, command and file checks, formatting, notifications, context and cost-related data. The distinction that matters for observability: hooks see local tool activity; model-request metering tells you what the session costs.
Set up hooks in settings.json
Documentation checked September 25, 2026. The observed changelog head was 2.1.285. These examples use Python 3.9 or later on macOS, Linux or WSL.
The hook locations reference defines four settings scopes:
| Scope | Location | Use it for |
|---|---|---|
| User | ~/.claude/settings.json | Personal hooks across projects |
| Shared project | .claude/settings.json | Reviewed hooks committed with the repo |
| Local project | .claude/settings.local.json | Personal hooks in one repo |
| Managed | Organization-deployed settings | Administrator policy |
Ordinary settings precedence is managed, command line, local project, shared project, then user; hook arrays merge across applicable settings sources instead of replacing one another.
mkdir -p .claude/hooks
chmod 700 .claude/hooks
claude --versionSave the chosen scripts there, merge their event arrays into one hooks object, and inspect the read-only /hooks browser inside Claude Code.
Each settings tab is a complete object for an empty settings file. Preserve existing settings and append groups under existing event names. Do not concatenate JSON objects or create duplicate hooks keys. Direct edits are normally hot-reloaded; restart if a saved handler remains missing.
The examples use exec-form handlers: python3 is the executable and args contains the script path. Paths with spaces stay one argument, and the Python files need no executable permission. ${CLAUDE_PROJECT_DIR} identifies the starting project; input cwd follows Claude's current directory and worktree. Use that current cwd for repository commands.
Native Windows needs adaptations: fcntl is Unix-only, Bash filters do not cover PowerShell, and npm's .cmd shim requires different execution handling. Anthropic documents these Windows execution differences.
Match the event's target
On tool events, matcher filters tool_name, not filenames. Edit|Write matches those two names; omission, "" and "*" match all. Matchers are case-sensitive. Regex matching is unanchored, so use ^Edit$ for a whole-string regex match. See the matcher rules.
Use mcp__server__.* for an MCP server's tools. Inspect file paths inside the script for filename policies. Stop and UserPromptSubmit have no matcher filtering.
All matching handlers run in parallel, including handlers in one group. A denial does not cancel a logger. Combine dependent input mutations in one handler and avoid competing formatters on the same file.
Combined settings: tool logger and destructive Bash filter
Save both scripts from recipes 1 and 2, then merge this subset into your settings. Both PreToolUse handlers run for a Bash attempt, even when the filter denies it.
{
"hooks": {
"PreToolUse": [
{"hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.py"],
"timeout": 5
}]},
{"matcher": "Bash", "hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/block-destructive.py"],
"timeout": 5
}]}
],
"PostToolUse": [{"hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.py"],
"timeout": 5
}]}],
"PostToolUseFailure": [{"hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.py"],
"timeout": 5
}]}]
}
}An attempt log proves the handler observed a call, not that the tool executed. Check both registration and behavior before adding more handlers.
Events, stdin and exit codes
A command hook receives one JSON object on stdin. Common fields include session_id, hook_event_name, cwd and transcript_path, with event-specific exceptions. Tool events add tool_name and tool_input. File tools use file_path; PostToolUseFailure uses top-level error.
The five current handler types are command, http, mcp_tool, prompt and agent. This collection uses commands. Prompt and agent handlers perform model evaluation; agent hooks are experimental.

The events these recipes use
The reference lists 33 events as of September 25, 2026. This table covers the working subset; each event has its own input and output schema.
| Event | Match target | Effect of command exit 2 |
|---|---|---|
| SessionStart | Source | Does not block startup |
| UserPromptSubmit | None | Rejects prompt |
| PreToolUse | Tool name | Blocks tool call |
| PostToolUse | Tool name | Feedback; cannot undo tool |
| PostToolUseFailure | Tool name | Feedback; cannot undo failure |
| Notification | Notification type | Ignored |
| Stop | None | Keeps main agent working |
| SubagentStop | Agent type | Keeps subagent working |
| PreCompact | Manual or auto | Blocks compaction |
| PostCompact | Manual or auto | Does not block |
| PreModelSwitch | Target canonical model | Blocks switch |
| SessionEnd | End reason | Does not block |
Source: exit-code behavior by event. Current PreCompact hooks can block compaction.
The other 21 documented events
| Event | Match target | Effect of command exit 2 |
|---|---|---|
| Setup | Trigger | Does not block |
| UserPromptExpansion | Command name | Blocks expansion |
| PermissionRequest | Tool name | Requires nested JSON decision |
| PermissionDenied | Tool name | Ignored; already denied |
| PostToolBatch | None | Stops loop before next model call |
| MessageDisplay | None | Original text displays |
| SubagentStart | Agent type | Does not block |
| TaskCreated | None | Rolls back creation |
| TaskCompleted | None | Prevents completion |
| StopFailure | Error | Ignored except terminal side effect |
| TeammateIdle | None | Keeps teammate working |
| InstructionsLoaded | Load reason | Ignored |
| ConfigChange | Source | Blocks change except managed policy |
| CwdChanged | None | Does not block |
| DirectoryAdded | Source | Does not block |
| FileChanged | Literal filename/watch list | Does not block |
| WorktreeCreate | None | Any nonzero exit fails creation |
| WorktreeRemove | None | Nonzero fails if directory remains |
| PostModelSwitch | Target canonical model | Does not block |
| Elicitation | MCP server | Denies elicitation |
| ElicitationResult | MCP server | Declines response |
See the event-specific exit table before applying a decision pattern to another event.
Exit codes and JSON decisions
| Output convention | Result |
|---|---|
| Exit 0, no output | No decision; normal permissions apply |
| Exit 2, reason on stderr | Blocks where the event supports it |
| Exit 0, one JSON object on stdout | Structured decision or context |
| Other exits without valid decision JSON | Ordinarily a non-blocking error |
Valid schema-checked JSON can control standard-decision events on other exit codes, so exit 1 is not a reliable denial. PermissionRequest requires its nested decision object; exit 2 alone does not deny it.
Return one JSON object without mixing in log output. For PreToolUse, put the decision inside hookSpecificOutput; context also belongs there as additionalContext with the matching hookEventName. A Stop block means keep working.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "This operation is not permitted."
}
}
10 practical Claude Code hook examples
| Hook | Event | Blocks? | What it does |
|---|---|---|---|
| 1. Tool log | PreToolUse, PostToolUse, PostToolUseFailure | No | Records attempts and outcomes |
| 2. Destructive Bash filter | PreToolUse | Yes | Catches removal and force-push patterns |
| 3. Secret-path protection | PreToolUse | Yes | Denies selected file paths |
| 4. Formatter | PostToolUse | No | Formats the changed file |
| 5. Project checks | Stop | Continues once | Runs a non-watching check |
| 6. Attention notification | Notification | No | Sends a desktop alert |
| 7. Git context | SessionStart | No | Adds branch, status and commits |
| 8. Prompt credential check | UserPromptSubmit | Yes | Rejects credential-like text |
| 9. Cache-cost estimates | SessionStart, PreModelSwitch | No | Logs operation estimates |
| 10. Transcript snapshot | PreCompact | No | Copies available transcript bytes |
The formatter and Stop check need repository dependencies. Install a pinned Prettier locally, such as the installation guide's prettier@3.9.9, and put node and ruff on the hook PATH. The published Prettier package provides bin/prettier.cjs. For recipe 5, define a reviewed check:agent npm script that runs your tests or typecheck and exits without watch mode; npm run executes that project-defined script.
1. Log tool attempts and outcomes as JSONL
Record identifiers and timing at PreToolUse, PostToolUse and PostToolUseFailure, using the documented tool-event fields.
{
"hooks": {
"PreToolUse": [{"hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.py"], "timeout": 5
}]}],
"PostToolUse": [{"hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.py"], "timeout": 5
}]}],
"PostToolUseFailure": [{"hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.py"], "timeout": 5
}]}]
}
}import fcntl
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
os.umask(0o077)
data = json.load(sys.stdin)
fields = ("session_id", "prompt_id", "hook_event_name", "tool_name",
"tool_use_id", "agent_id", "agent_type", "duration_ms")
entry = {key: data[key] for key in fields if key in data}
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
entry["has_error"] = "error" in data
folder = Path.home() / ".claude" / "hook-logs"
folder.mkdir(parents=True, exist_ok=True)
with (folder / "tool-events.jsonl").open("a", encoding="utf-8") as log:
fcntl.flock(log.fileno(), fcntl.LOCK_EX)
log.write(json.dumps(entry, separators=(",", ":")) + "\n")
log.flush()Gotcha: a denied attempt can lack an outcome event; the append lock prevents interleaved records, but use IDs to correlate parallel calls.
2. Block common destructive Bash patterns
Inspect tool_input.command before Bash executes, following the official validator pattern, and reject recursive force removal or force pushes.
{
"hooks": {
"PreToolUse": [{"matcher": "Bash", "hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/block-destructive.py"], "timeout": 5
}]}]
}
}import json
import re
import sys
try:
data = json.load(sys.stdin)
command = data["tool_input"]["command"]
if not isinstance(command, str):
raise ValueError("command is not text")
except (ValueError, KeyError, TypeError):
print("Cannot validate this command: invalid hook input.", file=sys.stderr)
sys.exit(2)
reason = None
for match in re.finditer(r"\brm\s+((?:(?:--[\w-]+|-[A-Za-z]+)\s+)+)", command):
options = match.group(1).split()
flags = "".join(option[1:] for option in options
if not option.startswith("--"))
recursive = "r" in flags or "R" in flags or "--recursive" in options
forced = "f" in flags or "--force" in options
if recursive and forced:
reason = "Recursive force removal is blocked. Remove specific files or ask the user."
break
if re.search(r"\bgit\b[^\n]*\bpush\b[^\n]*(?:--force(?:-with-lease)?\b|\s-[A-Za-z]*f[A-Za-z]*(?:\s|$))", command):
reason = "Force pushes are blocked. Use a normal push or ask the user."
if reason:
print(reason, file=sys.stderr)
sys.exit(2)Gotcha: this includes --force-with-lease and catches accidents, but aliases, scripts and alternative syntax evade text filters; use permissions and isolation for stronger controls.
3. Protect secret paths from file tools
Check direct and resolved paths before Read, Edit, Write and NotebookEdit, with permission-rule companions for access that bypasses those tools.
{
"permissions": {
"deny": [
"Read(//**/.env*)", "Edit(//**/.env*)",
"Read(//**/secrets/**)", "Edit(//**/secrets/**)",
"Read(~/.ssh/**)", "Edit(~/.ssh/**)"
]
},
"hooks": {
"PreToolUse": [{"matcher": "Read|Edit|Write|NotebookEdit", "hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-secrets.py"], "timeout": 5
}]}]
}
}import json
import sys
from pathlib import Path
try:
data = json.load(sys.stdin)
tool_input = data["tool_input"]
if not isinstance(tool_input, dict):
raise ValueError("invalid tool input")
raw = tool_input.get("file_path") or tool_input.get("notebook_path")
if not isinstance(raw, str) or not raw:
raise ValueError("missing path")
path = Path(raw).expanduser()
if not path.is_absolute():
path = Path(data["cwd"]) / path
candidates = (path, path.resolve())
except (ValueError, KeyError, TypeError, OSError, RuntimeError):
print("Cannot validate the target file path.", file=sys.stderr)
sys.exit(2)
for candidate in candidates:
parts = {part.lower() for part in candidate.parts}
name = candidate.name.lower()
if (name == ".env" or name.startswith(".env.") or
candidate.suffix.lower() in {".pem", ".key"} or
name in {"id_rsa", "id_ed25519", "credentials.json"} or
parts.intersection({"secrets", ".ssh"})):
print("Secret-file access is blocked by project policy.", file=sys.stderr)
sys.exit(2)Gotcha: this includes .env.example; the broader .env* permission rule also covers prompt @file references that skip Read hooks, while arbitrary subprocess reads still need filesystem restrictions.
4. Format files after Edit and Write
After a successful Edit or Write, format the changed JS/TS file with local Prettier or the changed Python file with Ruff.
{
"hooks": {
"PostToolUse": [{"matcher": "Edit|Write", "hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/format-file.py"], "timeout": 40
}]}]
}
}import json
import shutil
import subprocess
import sys
from pathlib import Path
def report(message):
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PostToolUse", "additionalContext": message
}}))
data = json.load(sys.stdin)
try:
file = Path(data["tool_input"]["file_path"]).resolve()
if not file.is_file() or file.suffix not in {".js", ".jsx", ".ts", ".tsx", ".py", ".pyi"}:
sys.exit(0)
repo = subprocess.run(["git", "rev-parse", "--show-toplevel"],
cwd=data["cwd"], capture_output=True, text=True, timeout=5)
if repo.returncode:
sys.exit(0)
root = Path(repo.stdout.strip()).resolve()
relative = file.relative_to(root)
if set(relative.parts) & {".git", "node_modules", "dist", "build"}:
sys.exit(0)
if file.suffix in {".py", ".pyi"}:
argv = [shutil.which("ruff") or "ruff", "format", "--force-exclude", str(file)]
else:
cli = root / "node_modules/prettier/bin/prettier.cjs"
argv = ["node", str(cli), "--write", "--ignore-unknown", str(file)]
result = subprocess.run(argv, cwd=root, capture_output=True, text=True, timeout=30)
if result.returncode:
report("Formatter failed. Check its installation and configuration, then rerun it.")
except (OSError, ValueError, subprocess.TimeoutExpired):
report("Formatter did not complete; the edit already happened.")Gotcha: Bash-written files bypass this hook; keep formatter ignores current, and retain --force-exclude because Ruff otherwise processes explicitly supplied excluded files.
5. Run checks on Stop with one corrective pass
On Stop, run the project's non-watching check:agent script for a dirty Git tree and use stop_hook_active to permit one corrective continuation.
{
"hooks": {
"Stop": [{"hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-stop.py"], "timeout": 120
}]}]
}
}import json
import subprocess
import sys
def block(reason):
print(json.dumps({"decision": "block", "reason": reason}))
data = json.load(sys.stdin)
if data.get("stop_hook_active") or data.get("background_tasks"):
sys.exit(0)
try:
repo = subprocess.run(["git", "rev-parse", "--show-toplevel"],
cwd=data["cwd"], capture_output=True, text=True, timeout=5)
if repo.returncode:
sys.exit(0)
root = repo.stdout.strip()
status = subprocess.run(["git", "--no-optional-locks", "status", "--porcelain"],
cwd=root, capture_output=True, text=True, timeout=5)
if status.returncode:
block("Could not inspect the working tree. Check Git before reporting completion.")
sys.exit(0)
if not status.stdout:
sys.exit(0)
check = subprocess.run(["npm", "run", "check:agent"], cwd=root,
capture_output=True, text=True, timeout=100)
if check.returncode:
output = (check.stdout + check.stderr)[-3000:]
block("Project checks failed. Fix or explain the blocker, then rerun checks.\n" + output)
except (OSError, subprocess.TimeoutExpired):
block("Project checks could not finish. Inspect dependencies, timeout and check:agent.")Gotcha: the next Stop passes without rechecking, and the dirty tree can include earlier changes; keep CI as the merge gate and review test output before sending it into model context.
6. Notify when Claude needs attention
Handle permission_prompt and idle_prompt notifications with a fixed desktop message or terminal bell.
{
"hooks": {
"Notification": [{"matcher": "permission_prompt|idle_prompt", "hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/notify.py"], "timeout": 5
}]}]
}
}import json
import shutil
import subprocess
import sys
json.load(sys.stdin)
try:
if sys.platform == "darwin":
argv = ["osascript", "-e",
'display notification "Claude Code needs your attention" with title "Claude Code"']
elif shutil.which("notify-send"):
argv = ["notify-send", "Claude Code", "Claude Code needs your attention"]
else:
print(json.dumps({"terminalSequence": "\u0007"}))
sys.exit(0)
result = subprocess.run(argv, capture_output=True, timeout=3)
if result.returncode:
print(json.dumps({"terminalSequence": "\u0007"}))
except (OSError, subprocess.TimeoutExpired):
print(json.dumps({"terminalSequence": "\u0007"}))Gotcha: notifications are delayed, macOS needs Script Editor notification permission, and Linux needs a desktop daemon; check visible delivery, since a successful process does not prove an alert appeared.
7. Refresh Git context at SessionStart
At every SessionStart, including resume and post-compaction startup, inject the current branch, working-tree status and recent commits.
{
"hooks": {
"SessionStart": [{"hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/git-context.py"], "timeout": 15
}]}]
}
}import json
import subprocess
import sys
data = json.load(sys.stdin)
parts = []
checks = [
("Current branch", ["git", "branch", "--show-current"]),
("Working tree", ["git", "--no-optional-locks", "status", "--short"]),
("Recent commits", ["git", "log", "--oneline", "-5"])
]
for label, argv in checks:
try:
result = subprocess.run(argv, cwd=data["cwd"], capture_output=True,
text=True, timeout=3)
if result.returncode == 0:
parts.append(label + ":\n" + (result.stdout.strip()[:1800] or "(empty)"))
except (OSError, subprocess.TimeoutExpired):
pass
if parts:
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "SessionStart", "additionalContext": "\n\n".join(parts)
}}))Gotcha: filenames and commit messages become model context, so keep sensitive values out; --no-optional-locks avoids unnecessary locking during status inspection.
8. Catch common secrets pasted into prompts
Before UserPromptSubmit processes expanded pasted text, reject these selected credential-like patterns without printing their values.
{
"hooks": {
"UserPromptSubmit": [{"hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-prompt.py"], "timeout": 5
}]}]
}
}import json
import re
import sys
def block(reason):
print(json.dumps({
"decision": "block", "reason": reason,
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit", "suppressOriginalPrompt": True
}
}))
try:
prompt = json.load(sys.stdin)["prompt"]
if not isinstance(prompt, str):
raise ValueError("prompt is not text")
except (ValueError, KeyError, TypeError):
block("Prompt validation could not run.")
sys.exit(0)
patterns = [
r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----",
r"\bAKIA[0-9A-Z]{16}\b",
r"\bghp_[A-Za-z0-9]{30,}\b",
r"\bsk-(?:ant-)?[A-Za-z0-9_-]{24,}\b"
]
if any(re.search(pattern, prompt) for pattern in patterns):
block("Possible credential detected. Remove it, use a redacted placeholder and rotate exposed credentials.")Gotcha: these sample patterns miss encoded or unfamiliar secrets; suppressOriginalPrompt hides the block-message text but does not erase local history.
9. Record per-session cache-cost estimates
Log context tokens and estimated cache-write cost from resume/fork SessionStart and PreModelSwitch, using fields available in Claude Code 2.1.251 or later.
{
"hooks": {
"SessionStart": [{"matcher": "resume|fork", "hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/cache-cost.py"], "timeout": 5
}]}],
"PreModelSwitch": [{"hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/cache-cost.py"], "timeout": 5
}]}]
}
}import fcntl
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
os.umask(0o077)
data = json.load(sys.stdin)
estimate = data.get("estimated_cache_write_usd")
if isinstance(estimate, bool) or not isinstance(estimate, (int, float)):
sys.exit(0)
keys = ("session_id", "hook_event_name", "source", "model", "from_model",
"to_model", "context_tokens", "prompt_cache_likely_expired",
"prompt_cache_warm", "cache_ttl", "pricing", "estimated_cache_write_usd")
entry = {key: data[key] for key in keys if key in data}
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
folder = Path.home() / ".claude" / "hook-logs"
folder.mkdir(parents=True, exist_ok=True)
with (folder / "cache-cost-estimates.jsonl").open("a", encoding="utf-8") as log:
fcntl.flock(log.fileno(), fcntl.LOCK_EX)
log.write(json.dumps(entry) + "\n")
log.flush()Gotcha: estimates exclude response cost and can use default pricing for unknown gateway models; a canceled switch is not a charge, so do not sum these records as session spend.
10. Snapshot transcripts before compaction
At PreCompact, copy input transcript_path into a timestamped local backup directory.
{
"hooks": {
"PreCompact": [{"matcher": "manual|auto", "hooks": [{
"type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/backup-transcript.py"], "timeout": 15
}]}]
}
}import json
import os
import shutil
import sys
import time
from pathlib import Path
os.umask(0o077)
data = json.load(sys.stdin)
if not data.get("transcript_path"):
sys.exit(0)
source = Path(data["transcript_path"]).expanduser()
if not source.is_file():
sys.exit(0)
try:
folder = Path.home() / ".claude" / "transcript-backups" / str(time.time_ns())
folder.mkdir(parents=True)
shutil.copyfile(source, folder / "transcript.jsonl")
except OSError:
print("Transcript snapshot failed; compaction can continue.", file=sys.stderr)
sys.exit(1)Gotcha: asynchronous transcript writes can leave recent messages out; backups contain conversation data and need their own deletion policy.
Check the hooks before deploying them
Use a disposable Git/npm repository, dummy secret files and synthetic input. A fixture invokes the script without executing an embedded Bash command. Then trigger a harmless real event and inspect /hooks, the debug log and the resulting file or notification.
python3 -m json.tool .claude/settings.json > /dev/null
python3 -m compileall -q .claude/hooks
claude --debug-file /tmp/claude-hooks.logThe debug log records hook execution. Keep it private. Registration proves configuration was loaded; event evidence and observed effects establish that the handler ran.
| Hook | Fixture or check | Expected result |
|---|---|---|
| Tool log | Attempt, success and failure with the same tool ID | Silent exit 0; JSONL IDs, no raw input |
| Bash filter | Removal/force-push strings; git status; malformed JSON | Exit 2, 0 and 2 respectively |
| Secret paths | Dummy .env, ordinary path, symlink to dummy secret | Deny secret paths; allow ordinary file |
| Formatter | Unformatted JS/TS and Python; path with spaces | Changed bytes; excluded files unchanged |
| Stop checks | Failing script; active continuation; background tasks | Block JSON once; subsequent cases silent |
| Notification | Native desktop alert or registered bell | Observe alert, not just process exit |
| Git context | Repository, worktree, non-Git directory | Correct context; non-Git case silent |
| Prompt check | Fake key-shaped text, ordinary prose, malformed JSON | Block, pass, block; no echoed key |
| Cache estimate | Present estimate, missing estimate | Log estimate; missing value adds no record |
| Transcript backup | Dummy JSONL file | Identical copied bytes; private permissions |
Review privacy as part of that check. The loggers omit raw commands, prompts, file contents and error text. Their umask protects newly created files, not existing permissions. Set retention for both log directories and transcript backups; local owner-editable logs are diagnostic records.
Copy safe stdin fixtures for all ten scripts
Run from the disposable repository after saving the scripts. The helper prints the process status; use it without shell errexit when expecting exit 2.
probe_hook() {
printf '%s\n' "$2" | python3 ".claude/hooks/$1"
result=$?
printf '\nExit status: %s\n' "$result"
}
probe_hook log-tools.py '{"session_id":"demo","hook_event_name":"PreToolUse","tool_name":"Read","tool_use_id":"demo-tool"}'
probe_hook log-tools.py '{"session_id":"demo","hook_event_name":"PostToolUse","tool_name":"Read","tool_use_id":"demo-tool"}'
probe_hook log-tools.py '{"session_id":"demo","hook_event_name":"PostToolUseFailure","tool_name":"Read","tool_use_id":"demo-tool","error":"synthetic failure"}'
tail -n 3 "$HOME/.claude/hook-logs/tool-events.jsonl"
probe_hook block-destructive.py '{"tool_input":{"command":"rm -rf /tmp/example"}}'
probe_hook block-destructive.py '{"tool_input":{"command":"rm -r -f /tmp/example"}}'
probe_hook block-destructive.py '{"tool_input":{"command":"rm --recursive --force /tmp/example"}}'
probe_hook block-destructive.py '{"tool_input":{"command":"git push origin main --force"}}'
probe_hook block-destructive.py '{"tool_input":{"command":"git status"}}'
probe_hook block-destructive.py '{'
probe_hook protect-secrets.py '{"tool_input":{"file_path":"/tmp/.env.production"}}'
probe_hook protect-secrets.py '{"tool_input":{"file_path":"/tmp/src/app.py"}}'
probe_hook protect-secrets.py '{"tool_input":{}}'
printf '%s\n' 'const value={answer:42}' > 'scratch file.ts'
payload="$(python3 -c 'import json, os; print(json.dumps({"cwd": os.getcwd(), "tool_input": {"file_path": os.path.abspath("scratch file.ts")}}))')"
probe_hook format-file.py "$payload"
cat 'scratch file.ts'
payload="$(python3 -c 'import json, os; print(json.dumps({"cwd": os.getcwd(), "stop_hook_active": False}))')"
probe_hook check-stop.py "$payload"
probe_hook check-stop.py '{"stop_hook_active":true}'
probe_hook check-stop.py '{"background_tasks":[{"fixture":"active"}]}'
probe_hook notify.py '{}'
payload="$(python3 -c 'import json, os; print(json.dumps({"cwd": os.getcwd()}))')"
probe_hook git-context.py "$payload"
payload="$(python3 -c 'import json; print(json.dumps({"prompt": "sk-" + "x" * 32}))')"
probe_hook guard-prompt.py "$payload"
probe_hook guard-prompt.py '{"prompt":"Explain this function."}'
probe_hook guard-prompt.py '{'
probe_hook cache-cost.py '{"session_id":"demo","hook_event_name":"PreModelSwitch","context_tokens":100000,"estimated_cache_write_usd":0.5,"pricing":"catalog"}'
probe_hook cache-cost.py '{"session_id":"demo","hook_event_name":"SessionStart","source":"resume"}'
tail -n 1 "$HOME/.claude/hook-logs/cache-cost-estimates.jsonl"
printf '%s\n' '{"fixture":"dummy conversation"}' > dummy-transcript.jsonl
payload="$(python3 -c 'import json, os; print(json.dumps({"session_id": "demo", "transcript_path": os.path.abspath("dummy-transcript.jsonl")}))')"
probe_hook backup-transcript.py "$payload"
ls -lt "$HOME/.claude/transcript-backups"For the Stop failure case, set check:agent temporarily to node -e "process.exit(1)" and leave a dummy file untracked. Expect exit 0 with decision: "block"; the active-continuation and background-task fixtures stay silent. Restore the real check script and exercise its passing path.
The cache fixture assumes 100,000 context tokens and a $0.50 estimate. These are supplied sample values, not a calculated model price. Missing estimates must not become zero-spend records.
The notification probe prints fallback JSON; registered Claude Code interprets terminalSequence. Bell handling is interactive-only. Permission notifications arrive after roughly six seconds of no typing; idle notifications after roughly 60 seconds. Use PermissionRequest for immediate permission-event handling.
Also exercise a missing formatter, a non-Git cwd, a worktree, concurrent logger appends and a timeout. Test dummy @file access separately to check the permission companion. Path resolution alone does not prevent hard-link or filesystem-race access. Compare snapshot bytes and permissions, then delete the dummy files and backups.
Ruff fixture: excluded bytes stay unchanged
This fixture covers global exclusions, formatter-only exclusions and Ruff's default .venv exclusion. It includes a positive control so a wrapper that skips every file fails.
python3 - .claude/hooks/format-file.py <<'PY'
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
hook = Path(sys.argv[1]).resolve()
ruff = shutil.which("ruff")
if not ruff or not hook.is_file():
raise SystemExit("Need Ruff on PATH and the saved hook.")
print(subprocess.check_output([ruff, "--version"], text=True).strip())
with tempfile.TemporaryDirectory(prefix="ruff-hook-fixture-") as temporary:
root = Path(temporary).resolve()
subprocess.run(["git", "init", "-q", str(root)], check=True)
(root / "pyproject.toml").write_text(
'[tool.ruff]\nextend-exclude = ["generated"]\n'
'[tool.ruff.format]\nexclude = ["format_only.py"]\n', encoding="utf-8")
excluded = [root / "generated/example.py", root / "format_only.py",
root / ".venv/lib/example.py"]
included = root / "src/included.py"
original = b"x= 1\r\n"
for file in excluded + [included]:
file.parent.mkdir(parents=True, exist_ok=True)
file.write_bytes(original)
probe = subprocess.run([ruff, "format", "--check", "--no-force-exclude", str(file)],
cwd=root, capture_output=True, timeout=10)
assert probe.returncode == 1 and file.read_bytes() == original, file
payload = {"cwd": str(root), "tool_input": {"file_path": str(file)}}
result = subprocess.run([sys.executable, str(hook)], input=json.dumps(payload),
cwd=root, capture_output=True, text=True, timeout=40)
assert result.returncode == 0 and not result.stdout and not result.stderr, result
assert (file.read_bytes() == original) == (file in excluded), file
checked = subprocess.run([ruff, "format", "--check", "--force-exclude", str(included)],
cwd=root, capture_output=True, timeout=10)
assert checked.returncode == 0, checked
assert all(file.read_bytes() == original for file in excluded)
print("PASS: excluded bytes unchanged; included file formatted.")
PYExpected: excluded bytes retain their spacing and CRLF; the included file changes and passes a read-only check. Ruff's closest configuration wins; replacing the default exclusion list can remove .venv protection. Test your effective repository configuration too.
How do I track what a Claude Code session costs?
Hooks see tool events, not billing. Use statusLine or OpenTelemetry for client usage data, or route model requests through Requesty for per-request cost with a session tag.
| Surface | Useful data | Limitation |
|---|---|---|
| Local hook log | Tool attempts, IDs and outcomes | No billing totals |
| Hook cache estimates | Context and rebuild estimates | Operation-specific |
statusLine | Estimated session cost | Client estimate |
| OpenTelemetry | API token/cost metrics and events | Telemetry setup |
| Requesty | Routed request cost, tokens and latency | Gateway traffic only |
statusLine exposes cost.total_cost_usd through a separate callback. It is an estimate; don't sum cumulative snapshots or treat current-context tokens as accumulated billable input. OpenTelemetry metrics and API request events provide usage without enabling raw prompt logging.
Ordinary Stop input has no session cost, usage totals or response headers. The foreground Agent tool's usage covers its final API request, and transcript entries use an internal, changing format. Use supported metering instead of summing transcript guesses.

After configuring the gateway below, save this launcher outside an untrusted repository and run it from your project:
import os, uuid
session_id = str(uuid.uuid4())
env = os.environ.copy()
headers = [line for line in env.get("ANTHROPIC_CUSTOM_HEADERS", "").splitlines()
if line.split(":", 1)[0].strip().lower() != "x-requesty-session"]
env["ANTHROPIC_CUSTOM_HEADERS"] = "\n".join(headers + ["X-Requesty-Session: " + session_id])
print("Session ID: " + session_id, flush=True)
os.execvpe("claude", ["claude", "--session-id", session_id], env)Claude Code accepts newline-separated custom headers and an explicit session UUID; X-Requesty-Session becomes the custom analytics field Session, so compare it with the local hook ID after a harmless request and group cost or tokens by that field. The header is fixed at launch: /clear does not rotate it, so relaunch with the matching label when changing conversations or externally resuming one; settings env overrides must not replace that label.
Every Claude Code model request routed through Requesty is logged with cost, tokens and latency. Give each developer a key with a monthly spend cap; after the cap is reached, new requests for that key are blocked. Set up the gateway with these two variables:
export ANTHROPIC_BASE_URL="https://router.requesty.ai"
export ANTHROPIC_AUTH_TOKEN="$REQUESTY_API_KEY"Use your securely supplied Requesty key. Follow the Claude Code integration guide or create an account.
For the full credential setup, see Claude Code with an API key. Use developer-key attribution for the traffic owner and the launch label for the run. For headless runs, --max-budget-usd adds a client-side cap; it is not an interactive-session budget flag.
Debug hooks that are not working
| Symptom | Likely cause | Fix |
|---|---|---|
Missing from /hooks | Invalid JSON or wrong settings source | Parse settings; inspect effective scope |
| Registered but never fires | Wrong event or matcher | Match the event's target, including case |
| Script cannot start | Wrong path or hook PATH | Check executable and dependencies |
| JSON decision ignored | Wrong nesting or extra stdout | Emit one event-valid JSON object |
| Context never reaches Claude | Top-level additionalContext | Nest it under hookSpecificOutput |
| Stop keeps continuing | Unbounded feedback | Guard with stop_hook_active |
| Formatter misses files | Bash or another tool wrote them | Use a separate file-change workflow |
| Policy allows after failure | Error or timeout failed open | Inspect debug log; retain hard controls |
| No visible desktop alert | OS permissions or missing daemon | Test native notifier and observe display |
| Wrong repository context | Starting project used instead of cwd | Read current input cwd |
| Session tag absent or wrong | Settings override launch headers | Inspect env; compare local/gateway IDs |
Write execution details with claude --debug-file /tmp/claude-hooks.log; /debug enables logging mid-session, while --debug does not print it directly to the terminal. Command-hook PreToolUse timeouts do not block execution, and asynchronous hooks cannot retroactively deny a call. Keep blocking checks synchronous and short.
Keep hard controls outside the hook
Command hooks run with your full user permissions, so review repository settings before unattended claude -p or SDK execution. Configured tool hooks also run inside subagents, and PreToolUse denials precede permission-mode checks, but editable scripts and fail-open errors limit them as security controls. Put reviewed automation in project settings and organization policy in managed settings; keep filesystem isolation, CI, protected branches and gateway caps at their own boundaries. For programmatic callbacks in agents you build, use our Claude Agent SDK hooks guide.
Sources
Contracts and configuration checked September 25, 2026.
Frequently asked questions
- What are Claude Code hooks?
- Claude Code hooks are handlers that run automatically at lifecycle events, such as before a tool call or after a response. Current handler types include commands, HTTP endpoints, MCP tool calls, prompts and agents. Command hooks implement local checks without asking a model to evaluate them.
- Where do I configure Claude Code hooks?
- Use ~/.claude/settings.json for personal hooks across projects, .claude/settings.json for shared project hooks, or .claude/settings.local.json for personal project settings. Administrators can deploy managed hooks. The /hooks command is a read-only browser; edit settings JSON to change hooks.
- What is the difference between PreToolUse and PostToolUse?
- PreToolUse runs before execution and can deny a tool call. PostToolUse runs after a successful call and can provide feedback or run a formatter, but it cannot undo the operation.
- Why is my Claude Code hook not working?
- Check registration in /hooks, the event and matcher, executable paths, dependencies and the event-specific schema. Test the script with synthetic stdin and inspect a debug log. A missing executable or timed-out command hook can leave an operation unblocked.
- How do I stop a Stop hook from looping?
- Inspect stop_hook_active and define a bounded continuation policy. The example here permits one corrective pass, then lets the next Stop through without rechecking. Keep CI as the merge gate.
- Can Claude Code hooks track tokens and costs?
- Ordinary Stop input does not expose total session tokens, cost or API response headers. Some hooks expose cache rebuild estimates; statusLine and OpenTelemetry provide client usage data. Requesty meters routed requests and groups their cost and tokens by a custom launch-time Session label.
- SEP '26
Claude Agent SDK Hooks: TypeScript and Python Examples
Learn Claude Agent SDK hooks with paired TypeScript and Python examples for permissions, logging, redaction, context, stop checks, and usage tracking.
- SEP '26
Claude Code API Key: Setup Without a Subscription
Use a Claude Code API key instead of Pro or Max. Follow macOS, Linux and Windows setup, verify billing, compare costs and switch back without hidden overrides.
- FEB '26
Label your API keys: the cost-attribution trick most teams miss
Requesty API keys carry arbitrary key-value labels. That one feature unlocks per-team, per-feature, per-customer spend attribution without a single line of instrumentation code. Here's the pattern.
- JUL '26
$1.8k before anyone noticed: how to cap runaway agent spend
Retry storms, agent loops, and stolen keys are the three ways teams lose four figures of LLM budget in a day. A practical setup for hard caps, per key limits, alerts, and loop detection.
