name: writing-hooks description: "How to write Claude Code hooks -- event selection, hook types, matcher patterns, blocking vs advisory, portable paths. Use when creating hooks for quality gates, automation, or policy enforcement." version: 0.1.0
Writing Hooks
Scope: covers hooks.json authoring and hook script design. For plugin architecture, see [[writing-plugins]]. For rules (which are simpler but static), see [[writing-rules]].
1. Three Hook Types
| Type | What it does | When to use | Complexity |
|---|---|---|---|
command | Runs a shell script, reads JSON from stdin | Deterministic checks: file existence, JSON validation, regex matching | Medium |
prompt | Injects text into Claude's context | Advisory: reminders, context injection, style guidance | Low |
agent | Spawns a verification agent | Complex verification: code quality, semantic analysis, multi-file checks | High |
Type Selection Flowchart
Is the check deterministic (regex, file exists, JSON schema)?
YES --> command hook (shell script)
NO --> Does it need AI judgment?
YES --> agent hook
NO --> prompt hook (context injection)
Command Hook Example
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/check-loc.sh",
"timeout": 10000
}
]
}
]
}
}
Hook script receives JSON on stdin with tool name and parameters. It outputs JSON to stdout.
Prompt Hook Example
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Remember: this project uses Result<T, E> for error handling. Never use try/catch directly."
}
]
}
]
}
}
Agent Hook Example
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "agent",
"agent": "Verify the written file follows project conventions. Check: import order, export style, naming conventions. Report any violations."
}
]
}
]
}
}
2. Blocking vs Advisory
Blocking (PreToolUse with deny)
The hook prevents the tool from executing. Use for hard quality gates.
Script output for blocking:
{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "File exceeds 300 LOC limit (current: 342). Extract logic before writing."
}
}
When to block:
- Tests must pass before committing
- File exceeds size limit
- Required field missing from config
- Dangerous operation detected (force push, drop table)
Advisory (PostToolUse with message)
The hook adds a message to Claude's context after the action completes. Use for suggestions and reminders.
Script output for advisory:
{
"hookSpecificOutput": {
"message": "The file you just edited has no tests. Consider adding tests in __tests__/."
}
}
When to advise:
- Suggest related actions (run tests, update docs)
- Remind about conventions
- Surface contextual information
- Warn about potential issues without blocking
Decision Matrix
| Situation | Block or Advise? | Rationale |
|---|---|---|
| Test failure on commit | Block | Broken tests should never be committed |
| File over LOC limit | Block | Enforce hard limit |
| Missing JSDoc on export | Advise | Nice to have, not a hard requirement |
| No tests for new file | Advise | Reminder, not a gate |
| Force push to main | Block | Destructive, irreversible |
| Large file creation (>500 lines) | Advise | Might be intentional (generated code) |
Rule of thumb: block only what you would reject in a code review. Advise on everything else.
3. Event Selection Guide
| Event | When it fires | Common use cases |
|---|---|---|
PreToolUse | Before a tool executes | Block dangerous operations, validate inputs, check preconditions |
PostToolUse | After a tool succeeds | Trigger follow-up actions, lint changed files, update state |
PostToolUseFailure | After a tool fails | Error recovery, suggest alternatives, log failures |
UserPromptSubmit | When user sends a message | Context injection, session setup, mode activation |
Stop | When Claude stops responding | Cleanup, summary generation, state persistence |
SessionStart | Session begins | Environment validation, context loading, config checks |
Event Selection by Goal
| Goal | Event | Hook type |
|---|---|---|
| Prevent bad writes | PreToolUse + matcher Write|Edit | command |
| Lint after edit | PostToolUse + matcher Write|Edit | command |
| Inject project context | UserPromptSubmit | prompt |
| Validate environment on start | SessionStart | command |
| Save session summary on exit | Stop | agent |
| Recover from failed bash commands | PostToolUseFailure + matcher Bash | prompt |
4. Matcher Patterns
The matcher field uses regex to match tool names. It applies only to PreToolUse, PostToolUse, and PostToolUseFailure events.
| Pattern | Matches | Use case |
|---|---|---|
"Bash" | Bash tool only | Guard shell commands |
"Write|Edit" | Write or Edit | Guard file modifications |
"Write" | Write only | Guard new file creation |
"Edit" | Edit only | Guard file edits (not creation) |
"Read" | Read tool | Track what files Claude reads |
"mcp__.*" | All MCP tool calls | Guard external integrations |
"mcp__github__.*" | GitHub MCP tools | Guard GitHub operations |
"Task" | Task tool (agent dispatch) | Monitor agent dispatching |
".*" | Everything | Use carefully -- fires on every tool call |
Matcher Testing
Before deploying, verify your matcher with test cases:
| Matcher | Should match | Should NOT match |
|---|---|---|
"Write|Edit" | Write, Edit | Bash, Read, WriteFile |
"Bash" | Bash | BashScript, mcp__bash |
"mcp__github__.*" | mcp__github__create_pr | mcp__slack__send |
5. Portable Paths
Always use ${CLAUDE_PLUGIN_ROOT} for script paths in hooks.json. This variable resolves to the plugin's installation directory at runtime.
Correct
{
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/check-loc.sh"
}
Wrong (breaks on other machines)
{
"command": "/Users/joker/.claude/plugins/cache/xiaolai/my-plugin/0.1.0/scripts/check-loc.sh"
}
Script Location Convention
my-plugin/
hooks/
hooks.json # hook definitions
scripts/
check-loc.sh # hook scripts
validate-config.sh
lint-output.sh
Script Requirements
Every hook script must have:
- Shebang line:
#!/bin/bashor#!/usr/bin/env node - Executable permission:
chmod +x scripts/*.sh - JSON output: scripts must output valid JSON to stdout
- Stderr for logging: debug output goes to stderr, not stdout (stdout is parsed as JSON)
#!/bin/bash
# Read input from stdin
input=$(cat)
# Debug logging goes to stderr
echo "Hook triggered: $(date)" >&2
# Business logic
file_path=$(echo "$input" | jq -r '.toolInput.file_path // empty')
if [ -z "$file_path" ]; then
# Allow if we can't determine the file
echo '{"hookSpecificOutput":{"decision":"allow"}}'
exit 0
fi
loc=$(wc -l < "$file_path" 2>/dev/null || echo "0")
if [ "$loc" -gt 300 ]; then
echo "{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"File has $loc lines, exceeds 300 LOC limit\"}}"
else
echo '{"hookSpecificOutput":{"decision":"allow"}}'
fi
6. Fail-Open vs Fail-Closed
What happens when your hook script crashes?
Fail-Open (recommended default)
If the script crashes, allow the action. Safer for advisory hooks and non-critical checks.
#!/bin/bash
# Fail-open wrapper
set +e # Don't exit on error
result=$(your_check_logic 2>/dev/null)
exit_code=$?
if [ $exit_code -ne 0 ]; then
# Script failed -- allow the action (fail-open)
echo '{"hookSpecificOutput":{"decision":"allow"}}'
exit 0
fi
# Normal processing...
echo "$result"
Fail-Closed (security-critical only)
If the script crashes, deny the action. Use only for critical security gates.
#!/bin/bash
# Fail-closed wrapper
set +e
result=$(your_check_logic 2>/dev/null)
exit_code=$?
if [ $exit_code -ne 0 ]; then
# Script failed -- deny the action (fail-closed)
echo '{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"Safety check script failed -- blocking action as precaution"}}'
exit 0
fi
# Normal processing...
echo "$result"
When to Use Each
| Hook purpose | Fail mode | Rationale |
|---|---|---|
| LOC limit enforcement | Fail-open | Better to allow a large file than block all writes |
| Style reminder | Fail-open | Non-critical advisory |
| Prevent force push to main | Fail-closed | Destructive action, err on side of caution |
| Secret detection | Fail-closed | Security-critical, must not leak |
| Test runner | Fail-open | Test infra failures shouldn't block development |
7. Common Mistakes
| Mistake | Why it's wrong | Fix |
|---|---|---|
Blocking on PostToolUse | Action already happened -- too late to block | Use PreToolUse for blocking |
| Wrong event case | pretooluse instead of PreToolUse -- case-sensitive | Use exact case: PreToolUse, PostToolUse, etc. |
| Script not executable | Hook fails silently | Run chmod +x scripts/*.sh |
| Missing shebang | Script may run with wrong interpreter | Add #!/bin/bash or #!/usr/bin/env node |
| Hardcoded paths | Breaks on other machines | Use ${CLAUDE_PLUGIN_ROOT} |
| stdout pollution | Debug output mixed into JSON response | Use stderr for logging: echo "debug" >&2 |
| No timeout | Slow script blocks Claude indefinitely | Set "timeout": 10000 (10 seconds) |
Matcher too broad (".*") | Fires on every tool call, performance impact | Narrow to specific tools |
| No fail-open wrapper | Script crash = broken hook = frustrated user | Wrap in fail-open try/catch |
8. Quality Checklist
Before deploying hooks, verify:
- Each hook has the correct event type for its purpose
- Blocking hooks use
PreToolUse, notPostToolUse - Matchers are tested against expected and unexpected tool names
- All script paths use
${CLAUDE_PLUGIN_ROOT} - All scripts have shebangs and executable permissions
- All scripts output valid JSON to stdout
- Debug logging goes to stderr, not stdout
- Fail-open or fail-closed is explicitly chosen for each hook
- Timeouts are set (default: 10 seconds)
- Hooks are tested with: normal input, edge case input, missing input