Hooks¶
Level: Advanced · Time: ~50 min · Exam relevance: CCAR-P 7.1, 7.2, 7.3, 5.1, 5.3
In plain English¶
Everything Claude does in a session passes through predictable moments: a session starts, you submit a prompt, Claude is about to use a tool, a tool has finished, Claude is about to stop. A hook is your own code that Claude Code runs at one of those moments.
Hooks turn "please remember to…" into "this always happens":
- Format every file after Claude edits it.
- Block any shell command that touches production.
- Run the test suite before Claude is allowed to say it is finished.
- Add today's on-call rota to the context at session start.
- Send a notification when Claude is waiting for input.
The difference from CLAUDE.md is the difference between a sign on the wall and a lock on the door. The model can overlook a sign; it cannot walk through a lock.
Why it matters¶
- Deterministic guardrails (CCAR-P-5.1). A
PreToolUsehook can deny a tool call regardless of what the model decided. - Human-in-the-loop (CCAR-P-5.3). Hooks can force a permission prompt (
ask) for sensitive actions, even in auto mode. - Team workflow automation (CCAR-P-7.1, 7.2). Formatting, linting, test gates and notifications become shared, committed configuration.
- Operations (CCAR-P-7.3). Hooks provide audit trails and integrate Claude Code with existing logging, ticketing and chat tools.
How it works¶
Lifecycle events¶
Claude Code exposes many events (as of 2026-10-24). The ones you will use most:
| Event | When it fires | Can block? | Typical use |
|---|---|---|---|
SessionStart |
Session begins or resumes (matcher: startup, resume, clear, compact, fork) |
No | Inject context (stdout becomes context), set up environment |
UserPromptSubmit |
You submit a prompt, before Claude sees it | Yes | Reject prompts containing secrets; add context |
PreToolUse |
After Claude chooses tool inputs, before the tool runs | Yes | Allow, deny, ask, or modify the call |
PermissionRequest |
A tool call needs a permission decision | Yes | Auto-answer prompts by policy |
PostToolUse |
After a tool succeeds | Feedback only (the tool already ran) | Format, lint, scan output, give Claude feedback |
PostToolUseFailure |
After a tool fails | Feedback | Diagnostics |
Notification |
Claude Code sends a notification (for example waiting for permission) | No | Desktop or chat alerts |
Stop / SubagentStop |
Claude (or a subagent) is about to finish its response | Yes (force it to continue) | Test gates, completeness checks |
PreCompact / PostCompact |
Around context compaction | Varies | Preserve notes |
SessionEnd |
Session terminates | No | Clean-up, logging |
Others include InstructionsLoaded, ConfigChange, FileChanged, CwdChanged, SubagentStart, WorktreeCreate, PreModelSwitch, Elicitation and more; see the hooks reference.
Configuration structure¶
Hooks live in settings files (or plugin, skill and subagent definitions). There are three levels: event → matcher group → handlers.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard_bash.py"], "timeout": 10 }
]
}
]
}
}
- Locations:
~/.claude/settings.json(you, all projects),.claude/settings.json(team, committed),.claude/settings.local.json(you, this project), managed policy, pluginhooks/hooks.json, skill and subagent frontmatter. Hooks merge across levels rather than replacing each other. - Matchers:
"*"or empty matches everything; plain names match exactly ("Edit|Write"); anything else is an unanchored JavaScript regular expression ("mcp__github__.*"). For tool events the matcher is the tool name. if: an optional permission-rule filter on tool events, such as"if": "Bash(git push *)", so the handler only spawns for matching calls.- Handler types:
command(shell or exec form),http(POST the event JSON to a URL),mcp_tool(call a tool on an MCP server),prompt(single-turn model evaluation), andagent(a verifying subagent; experimental). - Timeouts default to 600 seconds for command, HTTP and MCP-tool hooks (shorter on some events), 30 for prompt and 60 for agent hooks. Matching hooks for an event run in parallel.
${CLAUDE_PROJECT_DIR}gives a stable path to project scripts.
Input and output¶
A command hook receives the event as JSON on stdin. Common fields include session_id, transcript_path, cwd, permission_mode and hook_event_name; tool events add tool_name, tool_input and tool_use_id (and agent_id/agent_type inside subagents). File-tool paths arrive absolute.
The hook communicates back through its exit code and stdout:
| Exit code | Meaning |
|---|---|
0 |
Success. If stdout is a single JSON object, its fields apply (decisions, context) |
2 |
Blocking error on events that can block: the action stops and your stderr is the reason given to Claude |
| Anything else | Non-blocking error; the action proceeds and a hook-error notice appears |
For PreToolUse, return a decision in hookSpecificOutput:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Production database commands are blocked by policy. Use the staging DSN."
}
}
permissionDecision can be allow (skip the prompt), deny, ask (force a prompt, even in auto mode) or defer (headless only). With several hooks, precedence is deny > defer > ask > allow. Deny and ask permission rules are still evaluated regardless of what a hook returns, so a hook cannot loosen a managed deny. updatedInput can rewrite the tool input; additionalContext adds text Claude sees.
PostToolUse and Stop use top-level decision: "block" with a reason. For Stop, blocking means "do not stop yet; here is why", and the input's stop_hook_active field tells you whether Claude is already continuing because of a stop hook, so you can avoid infinite loops.
Managing and debugging hooks¶
/hooksshows configured hooks."disableAllHooks": trueturns hooks off (it cannot disable managed hooks from outside managed settings).- Administrators can set
allowManagedHooksOnlyto block user, project and plugin hooks, andallowedHttpHookUrlsto restrict HTTP hook destinations. - Run
claude --debug(log at~/.claude/debug/<session-id>.txt) orclaude --debug-file <path>to see hook execution, output parsing and timing.
Example¶
A guard for shell commands, a formatter after edits, and a test gate before Claude stops.
.claude/settings.json¶
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard_bash.py"], "timeout": 10 }
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/format_python.py"], "timeout": 60 }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/require_tests.py"], "timeout": 300 }
]
}
]
}
}
.claude/hooks/guard_bash.py¶
#!/usr/bin/env python3
"""PreToolUse guard: deny shell commands that target production or read secrets."""
import json
import re
import sys
event = json.load(sys.stdin)
command = event.get("tool_input", {}).get("command", "")
RULES = [
(r"\bprod(uction)?[-_.]?(db|database)\b|DATABASE_URL_PROD", "Production database access is blocked; use staging."),
(r"(^|\s)(cat|less|head|tail|cp|scp)\s+[^|;&]*\.env\b", "Reading .env files is blocked; ask a human for the value you need."),
(r"\bkubectl\b.*--context[= ]\S*prod", "kubectl against production contexts is blocked."),
]
for pattern, reason in RULES:
if re.search(pattern, command, re.IGNORECASE):
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}}))
sys.exit(0)
if re.search(r"\bgit\s+push\b", command):
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "Pushing changes: please confirm the branch and remote.",
}}))
sys.exit(0) # no output: normal permission flow applies
.claude/hooks/format_python.py¶
#!/usr/bin/env python3
"""PostToolUse: format edited Python files with ruff if it is installed. Never blocks."""
import json
import shutil
import subprocess
import sys
event = json.load(sys.stdin)
path = event.get("tool_input", {}).get("file_path", "")
if path.endswith(".py") and shutil.which("ruff"):
subprocess.run(["ruff", "format", "--quiet", path], check=False) # argument list, no shell
sys.exit(0)
.claude/hooks/require_tests.py¶
#!/usr/bin/env python3
"""Stop hook: do not let Claude finish while the test suite fails."""
import json
import subprocess
import sys
event = json.load(sys.stdin)
if event.get("stop_hook_active"):
sys.exit(0) # already continuing because of this hook; avoid an infinite loop
result = subprocess.run(["python3", "-m", "pytest", "-q", "-x"], capture_output=True, text=True, check=False)
if result.returncode not in (0, 5): # 5 = no tests collected
tail = "\n".join(result.stdout.splitlines()[-15:])
print(json.dumps({"decision": "block",
"reason": f"Tests are failing. Fix them before finishing.\n{tail}"}))
sys.exit(0)
Test a hook without Claude by piping sample JSON into it:
echo '{"tool_name":"Bash","tool_input":{"command":"psql $DATABASE_URL_PROD -c \"select 1\""}}' \
| python3 .claude/hooks/guard_bash.py
Note: Pattern-matching on command strings is a useful extra layer, not a complete defence: commands can be written in many ways. Pair hooks with permission deny rules and the sandbox (page 4).
Enterprise use case¶
Northwind Lending (fictional) needed evidence for auditors that AI-assisted changes to its loan-servicing code were reviewed and that no production credentials were ever used from developer machines. The platform team deployed hooks through managed settings with allowManagedHooksOnly: true:
- A
PreToolUseHTTP hook posts every Bash, Edit, Write and MCP tool call to an internal policy service, which denies production endpoints and asks for confirmation ongit pushto release branches. - A
PostToolUsecommand hook appends tool name, file paths, session ID and user to a local audit log shipped to the SIEM. - A
Stophook requiresmake verify(tests, lint, secret scan) to pass before Claude can declare a task complete. - A
Notificationhook posts to the developer's chat when Claude is waiting for a permission decision.
Because the hooks are managed, developers cannot disable them with disableAllHooks from their own settings, and the auditors received a single configuration file plus SIEM evidence instead of per-team assurances.
Common mistakes¶
- Expecting exit code 1 to block. Only exit code 2 (or a JSON deny) blocks; other non-zero codes are non-blocking errors.
- Printing extra text with JSON on stdout, which turns the output into a parse failure.
- Stop hooks without a
stop_hook_activecheck, causing endless continuation. - Slow hooks on hot paths (every
PreToolUse), which make sessions sluggish. - Unquoted shell variables and string-built commands in hook scripts.
- Assuming hooks fire for
@file mentions. Files referenced with@are inserted without a tool call, soPreToolUseonReaddoes not fire; useReaddeny rules. - Treating command-string regexes as complete protection.
Security considerations¶
- Hooks run with your full user permissions and outside the sandbox. Review every hook as you would any script you run.
- Repository hooks are code from the repository. In interactive sessions Claude Code holds back hooks until you accept workspace trust; in
claude -pand SDK sessions the folder counts as trusted and committed hooks run. For untrusted repositories use--bareor--settings '{"disableAllHooks": true}'. - Use managed hooks (
allowManagedHooksOnly) for controls that must not be bypassed, andallowedHttpHookUrlsto stop hooks sending data to arbitrary endpoints. - Follow the reference's practices: validate input, quote variables, block path traversal, use absolute paths, skip sensitive files.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
| Hook never runs | Wrong matcher (case-sensitive), folder not trusted, or disableAllHooks set |
Check /hooks; accept trust; fix the matcher |
| "hook error" notice | Script crashed or printed invalid JSON | Run it manually with sample stdin; check claude --debug logs |
| Block not taking effect | Exited 1 instead of 2, or JSON malformed | Exit 2 with stderr, or print valid JSON and exit 0 |
| Claude never stops | Stop hook blocks unconditionally | Check stop_hook_active; make the condition achievable |
| Sessions feel slow | Expensive hook on every tool call | Narrow the matcher, add an if filter, or run asynchronously |
Check your understanding¶
- Which event and output let you prevent a tool call, and what are the four possible decisions?
- A hook exits with code 1 and prints an error. Does the tool call proceed?
- Why can a
PreToolUsehook returningallownot override a manageddenyrule? - What field prevents a
Stophook from looping forever? - Why are committed hooks a risk in
claude -ppipelines on external pull requests?
Answers
PreToolUsewithhookSpecificOutput.permissionDecision:allow,deny,askordefer(headless only). Exit code 2 also blocks.- Yes. Non-zero codes other than 2 are non-blocking errors (unless the hook is configured so that failure blocks).
- Deny and ask permission rules are still evaluated regardless of what a hook returns.
stop_hook_activein the hook input, which istruewhen Claude is already continuing because of a stop hook.claude -ptreats the folder as trusted and runs hooks from the repository's.claude/settings.json, so a malicious pull request could add a hook that runs arbitrary commands with the runner's permissions. Use--bare, disable hooks, or restrict setting sources.
Further reading¶
- Hooks reference and hooks guide (Claude Code Docs)
- Configure permissions: extend permissions with hooks (Claude Code Docs)
- Human oversight and logging and auditability in Module 12