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.

10 stars

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

$curl -o ~/.claude/skills/writing-hooks/SKILL.md --create-dirs "https://raw.githubusercontent.com/xiaolai/nlpm-for-claude/main/codex/skills/writing-hooks/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/writing-hooks/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How writing-hooks Compares

Feature / Agentwriting-hooksStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/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

Related Skills

We are still matching the closest adjacent skills for this page. In the meantime, continue through the full directory.