Writing a Claude Code hook: output channels and conventions

Which output channel actually reaches Claude for each of the ten lifecycle events, the seven conventions every hook script follows, and how to add one.

View source
On this page
  1. Reference by group
  2. Output rules per event
  3. Conventions
  4. 1. Gate on mix.exs
  5. 2. Fail open
  6. 3. Use if conditions to avoid shell spawns
  7. 4. grep -c needs ${VAR:-0}, not || echo 0
  8. 5. Anchor command patterns
  9. 6. Blame awareness
  10. 7. State goes in /tmp or $CLAUDE_PLUGIN_DATA
  11. Adding or changing a hook
  12. Testing a hook by hand
  13. MCP tool hooks
  14. Other targets

About this hook group

Hooks

Which output channel actually reaches Claude for each of the ten lifecycle events, the seven conventions every hook script follows, and how to add one.

Hooks — Contributor Guide

Implementation notes for the 23 hook scripts in this directory. For the user-facing explanation of what the hooks do, see HOOKS.md at the repo root.

hooks/
├── hooks.json          # Registration: event → matcher → script
├── scripts/            # 23 bash scripts
├── tests/              # Shell test suites
└── docs/               # Per-group reference (linked below)

Reference by group

GroupEventsScripts
Safety GatesPreToolUseblock-dangerous-ops, deps-audit-gate, freeze-gate
Code QualityPostToolUseformat-elixir, iron-law-verifier, debug-statement-warning, security-reminder
Failure RecoveryPostToolUseFailureelixir-failure-hints, error-critic
Context InjectionUserPromptSubmit, SubagentStartroute-intent, inject-iron-laws
Session LifecycleSessionStartsetup-dirs, detect-tidewave, detect-ash, check-scratchpad, check-resume, check-branch-freshness
Workflow StatePostToolUse, PreCompact, PostCompact, StopFailure, Stopplan-stop-reminder, log-progress, precompact-rules, postcompact-verify, stop-failure-log, check-pending-plans

Output rules per event

This is the table to check before writing any hook. Each event has a different channel that actually reaches somebody, and picking the wrong one produces a hook that runs correctly and is read by nobody.

EventHow to reach ClaudeHow to reach the userNotes
PreToolUsepermissionDecision + additionalContext (JSON)permissionDecisionReasonadditionalContext survives a blocked call (CC 2.1.110+)
PostToolUseexit 2 + stderrPlain stdout is verbose-mode only
PostToolUseFailurehookSpecificOutput.additionalContext
UserPromptSubmitstdout (injected) or additionalContextNever exit 2 — it erases the user’s prompt
SubagentStarthookSpecificOutput.additionalContext
SessionStartstdout (injected)stdoutOne of only two events whose stdout reaches Claude
PreCompactJSON systemMessagesystemMessageTop-level fields only — no hookSpecificOutput
PostCompactexit 2 + stderrSame channel as PostToolUse
StopFailurenothingnothingCC ignores exit code and output. Persist to a file instead
StopadditionalContext / exit 2 — but this continues the turnJSON systemMessagestdout is debug-log only

Conventions

1. Gate on mix.exs

Every Elixir-specific hook starts with:

proj="${CLAUDE_PROJECT_DIR:-$PWD}"
[ -f "$proj/mix.exs" ] || exit 0

Without it the plugin bleeds into every repo the user opens — creating .claude/plans/ in Rust projects, injecting Phoenix Iron Laws into Python subagents (issue #55). The only deliberate exception is the force-push block in block-dangerous-ops.sh, which is dangerous everywhere.

2. Fail open

No set -e in hook scripts. A hook that errors must degrade to “guard is off”, never to “session is broken”. Denials are expressed as data (JSON permissionDecision), never as an exit code, and hooks.json appends || exit 0 to the always-on Bash gate. See the fail-open contract in Safety Gates for the incident that established this rule.

3. Use if conditions to avoid shell spawns

Register file-type-specific hooks with an if so they never spawn on unrelated files:

{
  "type": "command",
  "if": "Edit(*.ex)",
  "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/format-elixir.sh",
  "timeout": 30
}

The scripts re-check anyway (defense in depth), but the if avoids the process spawn entirely.

4. grep -c needs ${VAR:-0}, not || echo 0

grep -c prints 0 and exits 1 on no match, so || echo 0 appends a second line and the two-line value breaks any numeric comparison downstream:

COUNT=$(grep -c '^\- \[ \]' "$file" 2>/dev/null)
COUNT=${COUNT:-0}

5. Anchor command patterns

Match at start-of-line or after a real shell separator, absorbing leading whitespace and subshell parens:

(^[[:space:](]*|[;&|]+[[:space:]]*)

A bare ^ lets a leading tab or two spaces evade the guard. Plain mid-line whitespace must not be an anchor, or quoted mentions inside echo start matching.

6. Blame awareness

Content scanners read tool_input.new_string (Edit) or tool_input.content (Write) — not the file on disk. Flagging pre-existing violations in untouched regions pushes Claude into unrequested refactors.

7. State goes in /tmp or $CLAUDE_PLUGIN_DATA

Per-session counters use /tmp/.claude-elixir-*/ keyed by session or command. Durable metrics use $CLAUDE_PLUGIN_DATA (CC v2.1.78+), which survives plugin updates. Never write scratch state into the user’s project.

Adding or changing a hook

  1. Write the script in scripts/, chmod +x, following the conventions above.
  2. Register it in hooks.json with a timeout and, where applicable, an if.
  3. Update the docs. Add it to the matching file in docs/, to the table in the root HOOKS.md, and to the hook list in CLAUDE.md. This is a required step, not a nicety — the docs are the only place the reasoning is recorded.
  4. Add a case to tests/ if the hook has non-trivial matching (see tests/block-dangerous-ops_test.sh).
  5. Run make lint and make ci.
  6. Add a CHANGELOG.md entry under [Unreleased].

Hook edits are picked up by /reload-plugins (CC v2.1.98+) without restarting.

Testing a hook by hand

Hooks read a JSON event on stdin:

echo '{"tool_name":"Bash","tool_input":{"command":"mix ecto.drop"}}' \
  | ./scripts/block-dangerous-ops.sh

echo '{"tool_input":{"file_path":"lib/a.ex","new_string":"String.to_atom(x)"}}' \
  | ./scripts/iron-law-verifier.sh; echo "exit=$?"

Run the shell suite:

bash tests/block-dangerous-ops_test.sh

MCP tool hooks

Claude Code 2.1.118+ supports type: "mcp_tool" hooks (server, tool, optional input with ${tool_input.field} substitution).

Caveat: SessionStart and Setup fire before MCP servers finish connecting, so service detection must use a direct probe — see detect-tidewave.sh. Reserve mcp_tool hooks for PreToolUse, PostToolUse, and Stop, where the connection is already live.

Other targets

targets/codex/hooks/hooks.json ships one synchronous native hook — the block-dangerous-ops.sh PreToolUse gate — using ${PLUGIN_ROOT} rather than ${CLAUDE_PLUGIN_ROOT}. Codex requires users to review and trust plugin hooks before they run. Amp, Pi, and OpenCode targets do not carry lifecycle hooks; the canonical set here is Claude Code only.