writing-hooks
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.
Best use case
writing-hooks is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
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.
Teams using writing-hooks should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/writing-hooks/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How writing-hooks Compares
| Feature / Agent | writing-hooks | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
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.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# Writing Hooks
> Scope: covers Claude Code `hooks.json` authoring and hook script design. **Hook event vocabularies are per-tool and NOT 1:1 mappable** (nlpm design decision #4): Claude uses `PreToolUse`/`PostToolUse`/`Stop`/etc.; Codex overlaps with Claude plus `PostCompact`/`SubagentStart`; Antigravity/Gemini uses a different `Before*/After* Agent/Model/Tool` decomposition. The hook-script design principles here (idempotency, fail-open, exit codes, portable paths) transfer across tools; the event names and config locations do not. For the authoritative per-tool event tables see [[nlpm:conventions-claude]] §7, [[nlpm:conventions-codex]] §6, [[nlpm:conventions-antigravity]] §5. 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
```json
{
"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
```json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Remember: this project uses Result<T, E> for error handling. Never use try/catch directly."
}
]
}
]
}
}
```
### Agent Hook Example
```json
{
"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**:
```json
{
"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**:
```json
{
"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
```json
{
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/check-loc.sh"
}
```
### Wrong (breaks on other machines)
```json
{
"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:
1. **Shebang line**: `#!/bin/bash` or `#!/usr/bin/env node`
2. **Executable permission**: `chmod +x scripts/*.sh`
3. **JSON output**: scripts must output valid JSON to stdout
4. **Stderr for logging**: debug output goes to stderr, not stdout (stdout is parsed as JSON)
```bash
#!/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.
```bash
#!/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.
```bash
#!/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`, not `PostToolUse`
- [ ] 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