Claude Code Hooks — Meta Reference This skill provides the definitive reference for creating Claude Code hooks. Use this when building automation that triggers on Claude Code events. When to Use This Skill Building event-driven automation for Claude Code Creating PreToolUse guards to block dangerous commands Implementing PostToolUse formatters, linters, or auditors Adding Stop hooks for testing or notifications Setting up SessionStart/SessionEnd for environment management Integrating Claude Code with CI/CD pipelines (headless mode) Quick Reference Event Trigger Use Case SessionStart Session begins/resumes Initialize environment UserPromptSubmit User submits prompt Preprocess/validate input PreToolUse Before tool execution Validate, block dangerous commands PermissionRequest Permission dialog shown Auto-allow/deny permissions PostToolUse After tool succeeds Format, audit, notify PostToolUseFailure After tool fails Capture failures, add guidance SubagentStart Subagent spawns Inspect subagent metadata Stop When Claude finishes Run tests, summarize SubagentStop Subagent finishes Verify subagent completion Notification On notifications Alert integrations PreCompact Before context compaction Preserve critical context Setup --init / --maintenance Initialize repo/env SessionEnd Session ends Cleanup, save state Hook Structure .claude/hooks/ ├── pre-tool-validate.sh ├── post-tool-format.sh ├── post-tool-audit.sh ├── stop-run-tests.sh └── session-start-init.sh Configuration settings.json { "hooks" : { "PostToolUse" : [ { "matcher" : "Edit|Write" , "hooks" : [ { "type" : "command" , "command" : "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-format.sh" } ] } ] , "PreToolUse" : [ { "matcher" : "Bash" , "hooks" : [ { "type" : "command" , "command" : "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-validate.sh" } ] } ] , "Stop" : [ { "hooks" : [ { "type" : "command" , "command" : "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-run-tests.sh" } ] } ] } } Execution Model (Jan 2026) Hooks receive a JSON payload via stdin (treat it as untrusted input) and run with your user permissions (outside the Bash tool sandbox). Default timeout is 60s per hook command; all matching hooks run in parallel; identical commands are deduplicated. Hook Input (stdin) { "hook_event_name" : "PreToolUse" , "tool_name" : "Bash" , "tool_input" : { "command" : "ls -la" } } Environment Variables (shell) Variable Description CLAUDE_PROJECT_DIR Absolute project root where Claude Code started CLAUDE_PLUGIN_ROOT Plugin root (plugin hooks only) CLAUDE_CODE_REMOTE "true" in remote/web environments; empty/local otherwise CLAUDE_ENV_FILE File path to persist export ... lines (available in SessionStart; check docs for Setup support) Exit Codes Code Meaning Notes 0 Success JSON written to stdout is parsed for structured control 2 Blocking error stderr becomes the message; JSON in stdout is ignored Other Non-blocking error Execution continues; stderr is visible in verbose mode Stdout injection note: for UserPromptSubmit , SessionStart , and Setup , non-JSON stdout (exit 0) is injected into Claude’s context; most other events show stdout only in verbose mode. Decision Control + Input Modification (v2.0.10+) PreToolUse hooks can allow/deny/ask and optionally modify the tool input via updatedInput . Hook Output Schema { "hookSpecificOutput" : { "hookEventName" : "PreToolUse" , "permissionDecision" : "allow" , "permissionDecisionReason" : "Reason shown to user (and to Claude on deny)" , "updatedInput" : { "command" : "echo 'modified'" } , "additionalContext" : "Extra context added before tool runs" } } Note: older decision / reason fields are deprecated; prefer the hookSpecificOutput.* fields. Example: Redirect Sensitive File Edits
!/bin/bash
set -euo pipefail INPUT = " $( cat ) " FILE_PATH = " $( echo " $INPUT " | jq -r '.tool_input.file_path // empty' ) "
Redirect package-lock.json edits to /dev/null
if [ [ " $FILE_PATH " == * "package-lock.json" ] ] ; then UPDATED_INPUT = " $( echo " $INPUT " | jq -c '.tool_input | .file_path = "/dev/null"' ) " jq -cn --argjson updatedInput " $UPDATED_INPUT " '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: "Redirected write to /dev/null", updatedInput: $updatedInput } }' exit 0 fi echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}' Example: Strip Sensitive Files from Git Add
!/bin/bash
set -euo pipefail INPUT = " $( cat ) " TOOL_NAME = " $( echo " $INPUT " | jq -r '.tool_name' ) " CMD = " $( echo " $INPUT " | jq -r '.tool_input.command // empty' ) " if [ [ " $TOOL_NAME " == "Bash" && " $CMD " =~ ^git [ [ :space: ] ] +add ] ] ; then
Remove .env files from staging
SAFE_CMD
" $( echo " $CMD " | sed 's/.env[^ ]*//g' ) " if [ [ " $SAFE_CMD " != " $CMD " ] ] ; then echo '{}' | jq -cn --arg cmd " $SAFE_CMD " '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: "Removed .env from git add", updatedInput: { command: $cmd } } }' exit 0 fi fi echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}' Prompt-Based Hooks For complex decisions, use LLM-evaluated hooks ( type: "prompt" ) instead of bash scripts. They are most useful for Stop and SubagentStop decisions. Configuration { "hooks" : { "Stop" : [ { "hooks" : [ { "type" : "prompt" , "prompt" : "Evaluate whether Claude should stop. Context JSON: $ARGUMENTS. Return {\"ok\": true} if all tasks are complete, otherwise {\"ok\": false, \"reason\": \"what remains\"}." , "timeout" : 30 } ] } ] } } Response Schema Allow: {"ok": true} Block: {"ok": false, "reason": "Explanation shown to Claude"} Combining Command and Prompt Hooks Use command hooks for fast, deterministic checks. Use prompt hooks for nuanced decisions: { "Stop" : [ { "hooks" : [ { "type" : "command" , "command" : ".claude/hooks/quick-check.sh" } , { "type" : "prompt" , "prompt" : "Verify code quality meets standards" } ] } ] } Hook Templates Pre-Tool Validation
!/bin/bash
set -euo pipefail INPUT = " $( cat ) " TOOL_NAME = " $( echo " $INPUT " | jq -r '.tool_name' ) " CMD = " $( echo " $INPUT " | jq -r '.tool_input.command // empty' ) " if [ [ " $TOOL_NAME " == "Bash" ] ] ; then
Block rm -rf /
if echo " $CMD " | grep -qE 'rm\s+-rf\s+/' ; then echo '{}' | jq -cn '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Dangerous rm command detected" } }' exit 0 fi
Block force push to main
if echo " $CMD " | grep -qE 'git\s+push.--force.(main|master)' ; then echo '{}' | jq -cn '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Force push to main/master not allowed" } }' exit 0 fi
Soft-warning: possible credential exposure
if echo " $CMD " | grep -qE '(password|secret|api_key)\s*=' ; then echo '{}' | jq -cn '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: "Possible credential exposure in command", additionalContext: "Command may include a secret. Confirm intent and avoid committing secrets." } }' exit 0 fi fi exit 0 Post-Tool Formatting
!/bin/bash
set -euo pipefail INPUT = " $( cat ) " TOOL_NAME = " $( echo " $INPUT " | jq -r '.tool_name' ) " FILE_PATH = " $( echo " $INPUT " | jq -r '.tool_input.file_path // empty' ) " if [ [ " $TOOL_NAME " =~ ^ ( Edit | Write ) $ && -n " $FILE_PATH " && -f " $FILE_PATH " ] ] ; then case " $FILE_PATH " in .js | .ts | .jsx | .tsx | .json | .md ) npx prettier --write " $FILE_PATH " 2
/dev/null || true ; ; *.py ) ruff format " $FILE_PATH " 2
/dev/null || true ; ; *.go ) gofmt -w " $FILE_PATH " 2
/dev/null || true ; ; *.rs ) rustfmt " $FILE_PATH " 2
/dev/null || true ; ; esac fi exit 0 Post-Tool Security Audit
!/bin/bash
set -euo pipefail INPUT = " $( cat ) " TOOL_NAME = " $( echo " $INPUT " | jq -r '.tool_name' ) " FILE_PATH = " $( echo " $INPUT " | jq -r '.tool_input.file_path // empty' ) " if [ [ " $TOOL_NAME " =~ ^ ( Edit | Write ) $ && -n " $FILE_PATH " && -f " $FILE_PATH " ] ] ; then
Check for hardcoded secrets
if grep -qE '(password|secret|api_key|token)\s[:=]\s["\x27][^"\x27]+["\x27]' " $FILE_PATH " ; then echo "WARNING: Possible hardcoded secret in $FILE_PATH "
&2 fi
Check for console.log in production code
if [ [ " $FILE_PATH " =~ \ . ( ts | js | tsx | jsx ) $ ] ] && grep -q 'console.log' " $FILE_PATH " ; then echo "NOTE: console.log found in $FILE_PATH "
&2 fi fi exit 0 Stop Hook (Run Tests)
!/bin/bash
set -euo pipefail
Run tests after Claude finishes
cd " $CLAUDE_PROJECT_DIR "
Detect test framework
if [ [ -f "package.json" ] ] ; then if grep -q '"vitest"' package.json ; then npm run test 2
&1 | head -50 elif grep -q '"jest"' package.json ; then npm test 2
&1 | head -50 fi elif [ [ -f "pytest.ini" ] ] || [ [ -f "pyproject.toml" ] ] ; then pytest --tb = short 2
&1 | head -50 fi exit 0 Session Start
!/bin/bash
set -euo pipefail cd " $CLAUDE_PROJECT_DIR "
Check git status
echo "=== Git Status ===" git status --short
Check for uncommitted changes
if ! git diff --quiet ; then echo "WARNING: Uncommitted changes detected" fi
Verify dependencies
if [ [ -f "package.json" ] ] ; then if [ [ ! -d "node_modules" ] ] ; then echo "NOTE: node_modules missing, run npm install" fi fi exit 0 Matchers Matchers filter which tool triggers the hook: Exact match: Write matches only the Write tool Regex: Edit|Write or Notebook.* Match all: * (also works with "" or omitted matcher) Security Best Practices HOOK SECURITY CHECKLIST [ ] Validate all inputs with regex [ ] Quote all variables: "$VAR" not $VAR [ ] Use absolute paths [ ] No eval with untrusted input [ ] Set -euo pipefail at top [ ] Keep hooks fast (<1 second) [ ] Log actions for audit [ ] Test manually before deploying Hook Composition Multiple Hooks on Same Event { "PostToolUse" : [ { "matcher" : "Edit|Write" , "hooks" : [ { "type" : "command" , "command" : ".claude/hooks/format.sh" } , { "type" : "command" , "command" : ".claude/hooks/audit.sh" } , { "type" : "command" , "command" : ".claude/hooks/notify.sh" } ] } ] } All matching hooks run in parallel. If you need strict ordering (format → lint → test), make one wrapper script that runs them sequentially. Debugging Hooks
Test a PostToolUse hook manually (stdin JSON)
export CLAUDE_PROJECT_DIR = " $( pwd ) " echo '{"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"' " $( pwd ) "'/src/app.ts" } } ' \ | bash .claude/hooks/post-tool-format.sh
Check exit code
echo $? Navigation Resources references/hook-patterns.md — Common patterns references/hook-security.md — Security guide data/sources.json — Documentation links