"Creates and improves event-driven hooks for Claude Code automation. Use when building, debugging, or refactoring PreToolUse guards, PostToolUse formatters, Stop hooks for testing, SessionStart environment setup, agent-based verification gates, or integrating Claude Code with CI/CD pipelines."
Resources
1Install
npx skillscat add costa-marcello/skillkit/cc-hooks Install via the SkillsCat registry.
Claude Code Hooks -- Reference
This skill provides the definitive reference for creating and improving Claude Code hooks -- event-driven scripts that respond to lifecycle events.When to use:
- Building or improving event-driven automation for Claude Code
- Creating or refactoring PreToolUse guards to block dangerous commands
- Debugging hooks that fail silently or produce unexpected results
- 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 | Decision Control | Hook Types | Use Case |
|---|---|---|---|
SessionStart |
No | command | Initialise environment, set CLAUDE_ENV_FILE |
UserPromptSubmit |
No | command, prompt | Preprocess/validate input |
PreToolUse |
allow/deny/ask + updatedInput | command, prompt | Validate, block dangerous commands |
PermissionRequest |
allow/deny/ask | command, prompt | Auto-allow/deny permissions |
PostToolUse |
block | command, prompt | Format, audit, notify |
PostToolUseFailure |
block | command, prompt | Capture failures, add guidance |
Notification |
No | command | Alert integrations |
SubagentStart |
No | command | Inspect subagent metadata |
SubagentStop |
block | command, prompt, agent | Verify subagent completion |
Stop |
block | command, prompt, agent | Run tests, summarise |
TeammateIdle |
exit code 2 only | command | Enforce quality gates before idle |
TaskCompleted |
exit code 2 only | command | Block task completion if criteria not met |
PreCompact |
No | command | Preserve critical context (matchers: manual, auto) |
SessionEnd |
No | command | Cleanup, save state (matchers: clear, logout, prompt_input_exit) |
Hook Structure
.claude/hooks/
├── pre-tool-validate.sh
├── post-tool-format.sh
├── post-tool-audit.sh
├── stop-run-tests.sh
└── session-start-init.shConfiguration
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
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
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"
}}Older decision/reason fields are deprecated. Use the hookSpecificOutput.* fields.
Input: PreToolUse event for an Edit targeting package-lock.json.
Output: Redirect the edit to /dev/null with an allow decision.
#!/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"}}'
**Strip Sensitive Files from Git Add**
Input: PreToolUse event for a Bash command starting with git add that includes .env files.
Output: Modified command with .env references removed.
#!/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 Stop Hook**
Input: Stop event with task context.
Output: LLM evaluates whether all tasks are complete.
{ "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"}
Input: PostToolUse event after any file write.
Output: Append a JSON line to an audit log (non-blocking).
#!/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')"
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ -n "$FILE_PATH" ]]; then
echo "{\"timestamp\":\"$TIMESTAMP\",\"tool\":\"$TOOL_NAME\",\"file\":\"$FILE_PATH\"}" \
>> "$CLAUDE_PROJECT_DIR/.claude/edit-audit.jsonl"
fi
exit 0Settings matcher: "matcher": "Edit|Write" with "async": true.
Input: SessionStart event at session launch.
Output: Write environment variables to CLAUDE_ENV_FILE so they persist across Bash calls.
#!/bin/bash
set -euo pipefail
cd "$CLAUDE_PROJECT_DIR"
# Persist environment variables for the session
if [[ -n "${CLAUDE_ENV_FILE:-}" ]]; then
echo "export PROJECT_NAME=$(basename "$CLAUDE_PROJECT_DIR")" >> "$CLAUDE_ENV_FILE"
echo "export NODE_ENV=development" >> "$CLAUDE_ENV_FILE"
fi
# Inject context into Claude's conversation
echo "=== Session Context ==="
git branch --show-current 2>/dev/null || echo "Not a git repo"
echo "Node: $(node --version 2>/dev/null || echo 'not installed')"
exit 0Stdout is injected into Claude's context. CLAUDE_ENV_FILE exports persist across all Bash commands in the session.
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.
Default to command hooks for fast, deterministic checks. Use prompt hooks only when the decision requires natural language reasoning:
{ "Stop": [{ "hooks": [
{ "type": "command", "command": ".claude/hooks/quick-check.sh" },
{ "type": "prompt", "prompt": "Verify code quality meets standards" }
]}] }Matchers
Matchers filter which tool triggers the hook:
- Exact match:
Writematches only the Write tool - Regex:
Edit|WriteorNotebook.* - MCP tools:
mcp__github__.*ormcp__<server>__<tool>(regex patterns) - Match all:
*(also works with""or omitted matcher) - Event-specific matchers:
PreCompactsupportsmanual/auto;SessionEndsupportsclear/logout/prompt_input_exit
Security Best Practices
All hook templates already demonstrate set -euo pipefail, quoted variables, and absolute paths. Beyond those defaults, check for:
- No
evalwith untrusted input (stdin JSON is attacker-controlled) - Target <1 second execution per hook. Profile with
time bash .claude/hooks/your-hook.sh < test-input.jsonif a hook exceeds this - Append structured JSON lines to an audit log file (see the Bash Command Logger and MCP Audit Logger templates in references/hook-templates.md)
- Test hooks manually before deploying (see references/troubleshooting.md)
Agent-Based Hooks
For multi-turn verification, use type: "agent". An agent hook spawns a subagent that can use tools (up to 50 turns) to verify work before allowing Claude to proceed. Supported on Stop and SubagentStop events.
{ "Stop": [{ "hooks": [{ "type": "agent", "prompt": "Run the test suite and verify all tests pass. Check that no console.log statements remain in production files.", "timeout": 120 }] }] }Agent hooks return the same {"ok": true} / {"ok": false, "reason": "..."} schema as prompt hooks, but can use tools to gather evidence before deciding.
Async Hooks
Add "async": true to run hooks in the background without blocking Claude. Useful for logging, notifications, and slow post-processing.
{ "PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": ".claude/hooks/async-test-runner.sh", "async": true }] }] }Async hooks do not block Claude's response. Their exit codes and stdout are ignored for decision control. Use them for fire-and-forget tasks like audit logging or CI triggers.
Hook Composition
Multiple hooks on the same event run in parallel:
{ "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" }
]}] }If you need strict ordering (format, then lint, then test), create one wrapper script that runs them sequentially.
Verification
After creating or modifying a hook, verify it works before relying on it:
# 1. Test with sample input
export CLAUDE_PROJECT_DIR="$(pwd)"
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}' \
| bash .claude/hooks/your-hook.sh
# 2. Check exit code
echo $? # 0 = success, 2 = blocking error
# 3. Validate JSON output
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}' | jq .
# 4. Run Claude with debug mode to confirm hook fires
claude --debugSee references/troubleshooting.md for full diagnostics when hooks fail silently.
References
- references/hook-templates.md -- 15 ready-to-use templates: pre-tool validation, post-tool formatting, security audit, stop hooks, session start/end, context re-injection, async test runner, session state persistence, MCP audit logging, infinite loop guard, desktop notifications, bash logging, protected files, sprint context, session archiving
- references/input-output-schemas.md -- Per-event JSON schemas (stdin input and stdout output) for all 14 hook events, plus tool-specific
tool_inputfields for Bash, Write, Edit, Read, Grep, Glob, and MCP tools - references/command-vs-prompt.md -- Decision tree for choosing between command, prompt, and agent hook types. Performance comparison, prompt authoring guidance, and combining strategies
- references/tool-names.md -- Complete tool name reference (19+ built-in tools, MCP naming convention), advanced matcher patterns (anchored, negative lookahead, case-insensitive), and common matcher mistakes
- references/troubleshooting.md -- Step-by-step diagnostics for hooks not triggering, command failures, prompt issues, infinite loops, output visibility, timeouts, environment variables, and common pitfalls