Skip to main content
What this unit solvesHooks are deterministic scripts that run automatically at 30 lifecycle event points around tool execution, session boundaries, and prompt submission: formatting, validation, interception, safety nets. They shift “things that must happen every time” from soft model constraints to hard system guarantees. Since Claude Code v2.1, the hook system has expanded substantially: 30 events, five handler types (command / http / mcp_tool / prompt / agent), and exit code 2 blocking semantics. This unit covers the major trigger-point semantics, settings.json configuration, practical templates, and the supply-chain risks that come with hooks as executable code.

Learning objectives

  • Name the 30 Claude Code hook events grouped by trigger timing, and state the semantics and typical use cases for PreToolUse, PostToolUse, Stop, and SessionStart.
  • Write a correct hooks block in settings.json including matcher and command, and distinguish the three matcher parsing rules.
  • Use exit 2 to block a PreToolUse tool call, and use additionalContext to inject extra context for Claude.
  • Identify the supply-chain risks of hooks as external executable code and perform a basic scan before introducing a third-party hook.
  • Wire this project’s normalize_punctuation.py and validate_kb --hook into a deterministic safety net using hooks.

1. What a hook is: deterministic automation that fills the “might not remember” gap

CLAUDE.md and rules are “please follow this” (soft constraints, context layer). A hook is “the system enforces this” (hard guarantee, shell layer). It runs outside the model’s reasoning chain: the hook runs on your shell, reads the tool-input JSON from stdin, and decides whether to allow the tool call. The model does not know the hook is running by default. The exception is when you explicitly inform it via additionalContext. Three key facts:
  • Hooks are deterministic: they do not rely on the model “remembering” or “judging.” If the shell says exit 2, it exits 2. Every time.
  • Hooks are executable code: installing a hook is equivalent to adding a shell command to your workflow. That is a supply-chain entry point.
  • There are far more trigger points than most people realize: as of 2026-06, there are 30 events grouped into four timing categories [1].
Division of responsibility with 04-1 / 04-2 / 04-4Writing “do not commit .env” in CLAUDE.md means the model will usually remember, but occasionally miss. Writing it in .claude/rules/security.md (path-scoped) means Claude receives the constraint when it reads relevant files, but it is still only context. Writing it as a hook (PreToolUse on Bash, matcher git commit *) means the shell checks staged files for .env and exits 2 to block it, every single time, without exception.There is only one criterion: “should the model remember this, or should the system guarantee it?” If the answer is the latter, use a hook.

2. The 30 trigger events grouped

Since Claude Code v2.1, the hook system covers 30 events ([1]), grouped into four timing categories.

2.1 Once per session

2.2 Once per conversation turn

2.3 Once per tool call (inside the agentic loop)

2.4 Subagent / task events

2.5 Async / side-channel

This unit focuses on the four high-frequency eventsCovering all 30 events would dilute the focus. In practice, 80% of hooks use PreToolUse, PostToolUse, Stop, and SessionStart. Consult the documentation [1] for the others when a specific need arises.

3. Configuration: the settings.json structure

Hook configuration lives in the hooks block of settings.json, three levels deep: event, matcher group, hook handlers ([1]).

3.1 Five handler types

The type field determines how the handler runs ([1]): The default type is command. Omitting type is equivalent to explicitly specifying command.

3.2 Configuration locations and scope

Managed level takes precedenceOrganizations can set allowManagedHooksOnly: true in a managed setting to block user-level, project-level, and plugin-level hooks. Plugins force-enabled via enabledPlugins are not affected [1].

3.3 Matcher syntax

The matcher parsing rule is chosen automatically based on the characters present ([1]): Each event matches a different field:
  • Tool events (PreToolUse / PostToolUse, etc.) match tool_name
  • SessionStart matches the source (startup / resume / clear / compact)
  • Notification matches the notification type
  • FileChanged is a literal filename (not regex, not an exact-match list)
  • Events with no matcher mechanism (UserPromptSubmit, PostToolBatch, Stop, TeammateIdle, TaskCreated, TaskCompleted, WorktreeCreate, WorktreeRemove, MessageDisplay, CwdChanged): always fire, or always ignored if a matcher is set
MCP tool matchers must include .*mcp__memory__.* matches all memory tools. mcp__memory is treated as an exact string and matches nothing [1].

3.4 Second-level filtering: if

The if field uses permission-rule syntax to provide finer-grained condition filtering ([1]):
Supported rule formats include "Bash(rm *)" and "Edit(*.ts)". && / || / list combinations are not supported. Split into multiple handlers for multi-condition logic.
if only applies to tool eventsOnly PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied evaluate if. For all other events, a handler with if will never run [1].

4. Input: the JSON on stdin

All command hooks receive a consistently structured JSON object from stdin ([1]); http hooks receive it as the POST body. Base shape:
Tool events also include:
When running inside --agent or a Subagent, agent_id and agent_type are added. Only SessionStart receives the model field [1].
Extracting fields from stdinBash example using jq:

4.1 Path placeholders

Use the args exec form to avoid quoting issues ([1]):
  • ${CLAUDE_PROJECT_DIR}: project root
  • ${CLAUDE_PLUGIN_ROOT}: plugin installation directory
  • ${CLAUDE_PLUGIN_DATA}: plugin persistent data
  • ${user_config.*}: plugin user settings

5. Exit codes and decision-making

command hooks signal their intent through exit codes ([1]):

5.1 What exit 2 actually blocks

Not all events treat exit 2 as a block. Events that truly block ([1]):
  • PreToolUse → blocks the tool call
  • UserPromptSubmit → rejects and clears the prompt
  • UserPromptExpansion → blocks expansion
  • Stop / SubagentStop / TeammateIdle → prevents stopping
  • PreCompact → blocks compact
  • PostToolBatch → stops the agentic loop before the next model call
  • WorktreeCreate → any non-zero exit fails the creation
exit 2 has no blocking effect on PostToolUse / PostToolUseFailure / Notification, and only shows stderr. Unix intuition says “exit 1 is an error and should block,” but Claude Code’s hook system treats exit 1 as a non-blocking error. To actually block, use exit 2.

5.2 Injecting additional context for Claude

Return JSON with hookSpecificOutput.additionalContext to make Claude aware of something (the corresponding hookEventName is required) ([1]):
Where the context is injected depends on the event:
  • SessionStart / Setup / SubagentStart → beginning of the conversation
  • UserPromptSubmit / UserPromptExpansion → attached to the prompt
  • PreToolUse / PostToolUse* / PostToolBatch → attached to the tool result
10,000-character limit. For longer values, write to a file and attach a preview. Values from multiple hooks are all injected. Use declarative statements, not imperative commands (to avoid triggering prompt injection defenses) [1].

5.3 General JSON output fields

5.4 Decision control modes (different events use different fields)

6. Five practical templates

6.1 PreToolUse: blocking a dangerous Bash command

Corresponding settings.json:
Why permissionDecision: deny instead of exit 2For PreToolUse, returning JSON with permissionDecision is cleaner than exit 2: you can attach permissionDecisionReason for Claude to read, and you can choose between deny (explicit refusal), allow (explicitly bypass the prompt), ask (return to user confirmation), or defer (fall through to normal flow). exit 2 can only communicate via stderr and cannot express allow [1].

6.2 PostToolUse: auto-formatting (this project)

Read the path from stdin, not from environment variablesThe script above scans for files to process on its own. To target “the file just edited,” parse .tool_input.file_path from the hook input JSON on stdin (see the jq example in Section 4). Claude Code does not provide path-carrying environment variables like $CLAUDE_TOOL_INPUT_FILE_PATH (as of 2026-06). The only variables available for expansion inside command strings are addressing variables such as ${CLAUDE_PROJECT_DIR}, ${CLAUDE_PLUGIN_ROOT}, and ${CLAUDE_PLUGIN_DATA} [1].
PostToolUse must have a timeout and run incrementallyWithout an explicit timeout, PostToolUse defaults to a 600-second limit [1]. With rapid sequential edits, multiple hooks stack up and consume resources. This project’s normalize_punctuation.py is lightweight on a single file, but for heavy hooks (tsc, ruff —fix) you must:
  • Set a timeout field (30-60 seconds) to prevent hangs
  • Use incremental flags like --incremental
  • On Windows, wrap the timeout in PowerShell (Start-Process -TimeoutSec) if you depend on GNU timeout

6.3 Stop: running wrap-up validation (this project)

validate_kb.py --hook runs in Stop hook mode: it blocks only on CRITICAL/HIGH findings, and fails open on tool errors so it does not disrupt normal flows. It serves as a deterministic safety net in this project: even if the natural-language-driven self-check is skipped, the hook will block at the end of the turn.

6.4 SessionStart: injecting environment variables

A SessionStart hook can persist environment variables via the $CLAUDE_ENV_FILE environment variable (which points to a session-specific temporary file) [1]:
This is the officially supported way to set environment variables in Claude Code (as of 2026-06, only SessionStart / Setup / CwdChanged / FileChanged support this).

6.5 Tool call logging (PostToolUse)

The minimum fields satisfy the audit requirements described in 01-4 Context Engineering: timestamp, session_id, tool, input. Entries can be replayed, audited, or fed to 04-4 Skills for pattern analysis.
Background executionAdd "async": true to prevent the hook from blocking the main flow ([1]):
Pair with "asyncRewake": true to wake Claude via a system reminder when the async hook exits 2.

7. Hooks inside Skill / Subagent frontmatter

Hooks are not limited to settings.json. Skills and Subagents can declare hooks in their frontmatter and those hooks are active only for the lifetime of that Skill / Subagent ([1]):
A Subagent’s Stop hook is automatically converted to SubagentStopA Stop hook declared in Subagent frontmatter is automatically converted to SubagentStop, no manual change required [1]. This makes “run validation when the subagent finishes” a one-liner inside the frontmatter.

8. Advanced: HTTP / MCP / prompt handlers

8.1 HTTP handler template

allowedEnvVars is an allowlist: only the listed environment variables are exposed to the hook. Do not write values directly into headers (they end up in the plugin config file). Reference them as $VAR instead [1].

8.2 MCP tool handler template

Useful for reusing MCP capabilities across hooks, for example running a security scan via MCP after every file edit.

8.3 Prompt handler template

Use when LLM judgment is needed but you do not want to run a full agent. Default timeout is 30 seconds [1].

9. Security: hooks are executable code; external sources are supply chain

The command field of a hook is executed directly by the shell. Installing an unvetted hook is equivalent to handing execution rights over a piece of third-party code.

9.1 Known threats

  • CVE-2025-59536 (CVSS 8.7): code inside .claude/ executes before the user approves the trust dialog. Fixed in Claude Code v1.0.111+ [2]. Unpatched versions are vulnerable to pre-planted malicious commands.
  • CVE-2026-21852: an attacker-controlled app overwrites ANTHROPIC_BASE_URL, redirecting API traffic and exfiltrating the API key before trust is granted. Fixed in v2.0.65+ [2].
  • Snyk ToxicSkills (2026-02): 36% of public skills contained prompt injection. Hooks and skills share the same supply-chain surface. Scan before installing.

9.2 Required scan before importing any external hook

Any hit requires a manual review of the surrounding context before installation.

9.3 Principle of least privilege

A hook’s command should be granted only the access required to do its job:
  • Do not read ~/.ssh, ~/.aws, or **/.env* inside a hook. These paths belong in permissions.deny.
  • Do not let hooks write outside the repo. Use ${CLAUDE_PROJECT_DIR} to stay inside the project.
  • Use allowedEnvVars to allowlist environment variables (HTTP handlers) rather than exposing the entire process environment.

9.4 Versioning and managed policy

  • disableAllHooks: true in settings temporarily disables all hooks (managed-level hooks can only be disabled by a managed-level setting) [1].
  • Organizations can set allowManagedHooksOnly: true to block user-level, project-level, and plugin-level hooks (except those force-enabled via enabledPlugins).
  • Keep Claude Code >= v2.0.65 (CVE-2026-21852 fix) and >= v2.1.139 (hooks use systemMessage / terminalSequence instead of writing directly to /dev/tty) [1, 2].

10. Tool comparison

Hook mechanism support varies widely across tools (as of 2026-06):
Name clarifications and scope
  • Claude Code’s hook system as of 2026-06 is a full 30-event lifecycle system ([1]). Other CLI tools’ “hooks” mostly refer to command hooks only, with far fewer events.
  • OpenAI Codex, Antigravity, GitHub Copilot CLI hook mechanisms are subject to each vendor’s current documentation. Entries marked “source pending” above lack authoritative citations.
  • Cursor as of 2026-06 does not provide a first-class hook mechanism in its CLI or IDE.
  • This project’s validate_kb --hook (Stop mode) is designed against the primary reference (Claude Code) semantics in this table.

11. Hands-on exercises

30-minute exercise
  1. PostToolUse auto-formatting: following this project’s PostToolUse template, wire normalize_punctuation.py into .claude/settings.json and edit any .md to observe automatic normalization.
  2. PreToolUse blocking dangerous Bash: write block-rm.sh with the corresponding settings. Try to have Claude run rm -rf /tmp/xxx and observe the permissionDecision: deny response and the reason Claude receives.
  3. Stop wrap-up validation: wire in validate_kb.py --hook, deliberately create a unit with a frontmatter missing an updated field, and observe the Stop hook blocking and reporting it.
  4. Supply-chain scan: run the rg scans from Section 9 against ~/.claude/ and .claude/ and confirm there are no suspicious outbound references and no hidden Unicode.
  5. SessionStart env injection: write a SessionStart hook that sets MY_FLAG=1 via CLAUDE_ENV_FILE, then run echo $MY_FLAG in a new session to verify it takes effect.

12. Common pitfalls

Anti-pattern list
  • PostToolUse hook without a timeout: each file save triggers a long task; rapid successive saves cause tasks to stack up and consume resources. Every hook should have a timeout and a reasonable ceiling.
  • Binding expensive full type-checking to every Write/Edit without --incremental: this slows iteration. tsc, mypy, and ruff all have incremental modes. Use them.
  • Copying a hook config from a public repo and pasting it directly without scanning the command content: supply-chain risk. Before pasting, run rg and read every command’s shell code.
  • Using exit 1 expecting it to block: Unix intuition says “exit 1 is an error and should block.” Claude Code’s hook system disagrees: exit 1 is a non-blocking error. Use exit 2, or return JSON with permissionDecision: deny, to actually block.
  • Blocking with PreToolUse exit 2 without giving Claude a reason: exit 2 feeds stderr back to Claude, but it is only plain text. For PreToolUse, returning JSON with permissionDecision: deny and permissionDecisionReason is more structured and easier for the model to understand and respect [1].
  • Writing “things you want the model to do” as hooks: hooks run at the shell layer, outside the model’s reasoning chain. Things you want the model to “remember” belong in CLAUDE.md or rules. Only things that need a “system-level guarantee” should go into hooks. Confusing the two results in hooks running context-maintenance logic at the shell layer, wasting resources for no benefit.
  • Mixing \| list syntax with other regex characters in matcher: \| only works in the exact-list syntax. Adding other characters (like Bash|Edit\.ts) causes the matcher to be parsed as a regex, which is probably not what you want [1].

Self-check

The bar for passing this unit
  1. Can you state the fundamental difference between a hook and CLAUDE.md / rules? (Shell determinism vs. context soft constraint)
  2. Can you write a PreToolUse hook that blocks git commit when .env is staged, in under 5 minutes?
  3. Can you state the semantics of exit 0, exit 2, and other exit codes across different events?
  4. Can you state the three supply-chain protection SOPs for hooks as executable code?
  5. In your current workflow, is there one thing that “should always happen but might be forgotten” that you could guarantee with a PostToolUse or Stop hook? Name the trigger event, the command to run, and the reasoning for choosing PostToolUse over Stop (or vice versa).

Sources and further reading

Factual claims are grounded in official documentation; fast-changing items are annotated as of 2026-05.
  • [1] Anthropic, “Hooks,” code.claude.com, 2026. [Online]. Available: https://code.claude.com/docs/en/hooks (as of 2026-06; covers 30 events, five handler types, three-mode matcher parsing, exit 2 blocking semantics, additionalContext injection, v2.1.139+ switch to terminalSequence)
  • [2] Anthropic, “Claude Code release notes,” code.claude.com, 2026. [Online]. Available: https://code.claude.com/docs/en/changelog (as of 2026-06; CVE-2025-59536 and CVE-2026-21852 fix versions)
  • [3] Anthropic, “Extend Claude with skills,” code.claude.com, 2026. [Online]. Available: https://code.claude.com/docs/en/skills (as of 2026-06; hooks configuration inside Skill frontmatter)
  • [4] Anthropic, “Create custom subagents,” code.claude.com, 2026. [Online]. Available: https://code.claude.com/docs/en/subagents (as of 2026-06; hooks inside Subagent frontmatter and automatic Stop to SubagentStop conversion)