Help us improve
Share bugs, ideas, or general feedback.
From nlpm
Guides writing Claude Code hooks: event selection, hook types (command/prompt/agent), matcher patterns, blocking vs advisory, and portable paths. Use when creating hooks for quality gates, automation, or policy enforcement.
npx claudepluginhub xiaolai/nlpm --plugin nlpmHow this skill is triggered — by the user, by Claude, or both
Slash command
/nlpm:writing-hooksThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
> 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...
Guides implementation of event-driven hooks in Claude Code plugins using prompt-based validation and bash commands for PreToolUse, Stop, and session events.
Guides creation of Claude Code plugin hooks for event-driven automation, including prompt-based and command hooks to validate tool use, enforce policies, and integrate external tools.
Create event-driven hooks for Claude Code automation. Use when the user wants to create hooks, automate tool validation, add pre/post processing, enforce security policies, or configure settings.json hooks. Triggers: create hook, build hook, PreToolUse, PostToolUse, event automation, tool validation, security hook
Share bugs, ideas, or general feedback.
Scope: covers Claude Code
hooks.jsonauthoring and hook script design. Hook event vocabularies are per-tool and NOT 1:1 mappable (nlpm design decision #4): Claude usesPreToolUse/PostToolUse/Stop/etc.; Codex overlaps with Claude plusPostCompact/SubagentStart; Antigravity/Gemini uses a differentBefore*/After* Agent/Model/Tooldecomposition. 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]].
| 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 |
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)
{
"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.
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Remember: this project uses Result<T, E> for error handling. Never use try/catch directly."
}
]
}
]
}
}
{
"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."
}
]
}
]
}
}
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:
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:
| 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.
| 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 |
| 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 |
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 |
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 |
Always use ${CLAUDE_PLUGIN_ROOT} for script paths in hooks.json. This variable resolves to the plugin's installation directory at runtime.
{
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/check-loc.sh"
}
{
"command": "/Users/joker/.claude/plugins/cache/xiaolai/my-plugin/0.1.0/scripts/check-loc.sh"
}
my-plugin/
hooks/
hooks.json # hook definitions
scripts/
check-loc.sh # hook scripts
validate-config.sh
lint-output.sh
Every hook script must have:
#!/bin/bash or #!/usr/bin/env nodechmod +x scripts/*.sh#!/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
What happens when your hook script crashes?
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"
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"
| 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 |
| 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 |
Before deploying hooks, verify:
PreToolUse, not PostToolUse${CLAUDE_PLUGIN_ROOT}