Hooks

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page is written against the Claude Code CLI 2.1.283, VS Code 1.139 with the GitHub Copilot Chat extension, and the GitHub Copilot CLI 1.0.88, all re-verified as current as of 2026-09-28; re-check the linked docs before relying on an exact field name, since both products change these surfaces often. The ai-catalog example is excerpted from this account’s ai-catalog repository, pinned to commit 58fb709 (main).

A hook is a shell command Claude Code or Copilot runs automatically at a fixed point in an agent’s turn — before a tool call, after a file edit, when a session starts, when the agent is about to stop — instead of that step depending on the model choosing to run it. Skills and instructions both only ever ask; a hook runs, every time its event fires, whether or not the model would have remembered to.

Claude Code hook events

The table below covers the most commonly used events, plus a few close relatives, verified against the Claude Code Hooks reference. "Can block" means the hook can stop or redirect the action it fires on — by exiting 2 or returning a JSON decision — rather than only observing it.

Event Fires Can block? Matches on

PreToolUse

Before a tool call runs.

Yes

Tool name (Bash, Edit|Write, mcp__.*)

PostToolUse

After a tool call finishes successfully.

No (the call already ran; exit 2 or decision: "block" only feeds a reason back to Claude)

Tool name

PostToolUseFailure

After a tool call fails.

No

Tool name

UserPromptSubmit

Before Claude processes a submitted prompt.

Yes

No matcher; fires on every prompt

Stop

When Claude is about to finish responding.

Yes

No matcher; fires on every stop attempt

SubagentStop

When a subagent is about to finish.

Yes

Agent type (Explore, Plan, a custom agent name)

SessionStart

When a session begins or resumes.

No

How it started (startup, resume, clear, compact, fork)

SessionEnd

When a session terminates.

No

Why it ended (clear, resume, logout, prompt_input_exit, other)

Notification

When Claude Code sends a desktop/UI notification, e.g. waiting on a permission prompt or gone idle.

No

Notification type (permission_prompt, idle_prompt, and others)

PreCompact

Before context compaction.

Yes (blocks the compaction)

What triggered it (manual, auto)

PostCompact

After compaction completes.

No

Same values as PreCompact

Claude Code documents a longer tail of events beyond this table — PermissionRequest, PermissionDenied, SubagentStart, ConfigChange, CwdChanged, FileChanged, PreModelSwitch/PostModelSwitch, WorktreeCreate/WorktreeRemove, TaskCreated/TaskCompleted, and a few more — for narrower or more recent use cases (auditing a configuration change, reloading environment variables on a directory change, gating a model switch). See the reference's own event list for the full, up-to-date set rather than treating this page’s table as exhaustive.

Matchers, the JSON contract and exit codes

Matchers

A matcher narrows which occurrences of an event a hook’s commands run for. Tool events (PreToolUse, PostToolUse, …​) match on tool name — a plain name, a |-separated alternation, or (as soon as the value contains any other character) an unanchored JavaScript regular expression, e.g. Bash, Edit|Write, mcpgithub.. "", an empty matcher, or omitting it runs the hook on every occurrence of the event. A separate if field, available only on the tool events, filters further using permission-rule syntax such as Bash(git ), so a hook can target only git subcommands within a broader Bash matcher; because that filtering is best-effort (it can’t always tell which subcommands a shell string runs), it narrows *when the hook runs, not a hard security boundary — use permission rules for that.

Input

Every hook receives one JSON object on stdin. Fields common to every event include session_id, transcript_path, cwd, permission_mode and hook_event_name; each event adds its own fields on top. A PreToolUse hook for a Bash call, for example, receives:

{
  "session_id": "abc123",
  "transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
  "cwd": "/home/user/my-project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test",
    "description": "Run test suite"
  },
  "tool_use_id": "toolu_01ABC123..."
}

A Stop hook additionally receives stop_hook_active (true when this turn is already continuing because of an earlier Stop-hook block) and last_assistant_message; a SessionStart hook receives source (the same value the matcher filters on).

Output: exit codes and JSON decisions

A hook script controls the outcome in one of two ways, and the reference recommends choosing one approach per hook: exit codes alone, or exit 0 plus a JSON object on stdout. If a hook mixes them, exit 2 still blocks and Claude Code still reads the JSON fields:

Exit code Meaning

0

No objection via the exit code. For PreToolUse this does not by itself approve the call — the normal permission flow still applies unless the hook’s JSON says otherwise. For UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch, plain-text stdout is added to Claude’s context.

2

Blocks the action on events that can block. The script’s stderr becomes the reason: fed back to Claude on events like PreToolUse and Stop; on events that can’t block it is shown to the user or ignored, depending on the event.

any other

Treated as a non-blocking error; the action proceeds. Valid JSON on stdout, if present, is read as a structured decision instead of the exit code deciding the outcome.

For finer control than block/allow, exit 0 and print a JSON object to stdout instead. The exact field names differ by event, verified against the reference's per-event sections:

  • PreToolUse nests its decision under hookSpecificOutput:

    {
      "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",
        "permissionDecisionReason": "Use rg instead of grep for better performance"
      }
    }

    permissionDecision is allow (skip the interactive prompt; deny/ask rules still apply), deny (cancel the call and send the reason to Claude), or ask (show the normal permission prompt).

  • Stop and SubagentStop use a top-level decision field instead:

    {
      "decision": "block",
      "reason": "Tests are still failing; keep working."
    }

    decision: "block" (or exit 2) forces another turn and feeds reason back to Claude as the next instruction; omit decision and the turn ends normally.

  • UserPromptSubmit injects text into context with additionalContext, returned inside hookSpecificOutput alongside hookEventName:

    {
      "hookSpecificOutput": {
        "hookEventName": "UserPromptSubmit",
        "additionalContext": "Current branch: release-42. Deploy freeze until Friday."
      }
    }

A malformed or unparseable JSON payload on stdout is reported as a non-blocking error rather than silently ignored, so a hook that builds its output with jq (rather than raw string concatenation) avoids a stray unescaped quote breaking the whole decision.

settings.json placement

Hooks live under a top-level hooks key, itself keyed by event name, in any of Claude Code’s settings files. Where a hook is added decides its scope and whether it can be committed:

File Scope Shareable

~/.claude/settings.json

Every project for the current user

No

.claude/settings.json

This project, everyone who checks it out

Yes — commit it

.claude/settings.local.json

This project, current user only

No — Claude Code keeps it out of git when it creates the file

Managed policy settings

Organization-wide, admin-controlled

Yes — and disableAllHooks in project/user settings cannot disable it

A plugin’s hooks/hooks.json

While the plugin is enabled

Yes — bundled with the plugin

A skill’s or subagent’s own hooks frontmatter (see Skills and Subagents & Custom Agents) registers hooks from the component itself: a subagent’s hooks run only while that subagent runs, and a skill’s hooks stay registered for the rest of the session once the skill is invoked.

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

Each event key holds an array of matcher groups, and each group’s hooks array can list more than one handler; all matching handlers run in parallel, and an identical handler defined in several settings files runs once. Besides type: "command", Claude Code also documents http (posts the input JSON to a URL), mcp_tool (calls an already-configured MCP tool) and prompt/agent hooks (route the decision through a model instead of a script, for judgment calls a deterministic check can’t make) — see the reference for those. Run /hooks inside a session to browse every currently configured hook grouped by event; the menu is read-only, so add or change one by editing the settings file directly.

Copilot hooks

GitHub Copilot ships an equivalent mechanism, verified against GitHub Copilot — Hooks and the Hooks JSON reference: repository-level hooks at .github/hooks/.json, plus personal hooks at ~/.copilot/hooks/.json for Copilot CLI. As of 2026-09-28, Copilot hooks apply to the Copilot cloud agent on GitHub.com and to the Copilot CLI; VS Code support is listed as a preview surface on GitHub’s customization cheat sheet (Copilot — Customization cheat sheet), so confirm current VS Code coverage there before relying on it.

A hook file’s top level carries a required "version": 1 and a hooks object keyed by event name (accepted in either camelCase, e.g. preToolUse, or the VS Code-compatible PascalCase, e.g. PreToolUse, which also switches the input payload to snake_case field names). Command hooks run a bash or powershell script (the cloud agent’s Linux sandbox only honors bash/command):

.github/hooks/format-on-edit.json
{
  "version": 1,
  "hooks": {
    "postToolUse": [
      {
        "type": "command",
        "bash": ".github/hooks/format.sh",
        "timeoutSec": 30
      }
    ]
  }
}

Copilot documents preToolUse, postToolUse, postToolUseFailure, sessionStart, sessionEnd, userPromptSubmitted, userPromptTransformed, preCompact, permissionRequest, agentStop, subagentStart, subagentStop, errorOccurred and notification — the same shape of lifecycle as Claude Code’s event set above, under different names for the equivalents (agentStop ~ Stop, userPromptSubmitted ~ UserPromptSubmit). A preToolUse command hook is documented as fail-closed: exit 2, a crash or any other non-zero exit denies the tool call, the opposite of Claude Code’s fail-open default for exit codes other than 2, so a buggy Copilot preToolUse script blocks work rather than silently letting it through. Timeouts are the exception: they are fail-open for every Copilot event, so a slow script lets the call proceed through the normal permission flow. preToolUse approves, denies or rewrites a call by printing a JSON object with a permissionDecision ("allow", "deny" or "ask"), a permissionDecisionReason (required when denying) and optionally modifiedArgs — the field names match Claude Code’s own hookSpecificOutput contract closely enough that a script written against one reference reads naturally against the other, though the two are documented and versioned independently, so verify both before sharing one script across tools.

Recipes

Each recipe below reads exactly the fields the corresponding event’s documented JSON input contract provides (see "Matchers, the JSON contract and exit codes" above), and shows the config plus the script it calls.

Auto-format on edit

Reformat whatever file Claude just edited or wrote, using the file path from tool_input.file_path:

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}

No separate script is needed here — the command itself reads tool_input.file_path from stdin via jq and pipes it to Prettier. See Claude Code — Automate actions with hooks (Auto-format code after edits).

Block dangerous commands

A PreToolUse hook on Bash inspects tool_input.command and exits 2 with a reason on stderr for a destructive pattern, which Claude Code both blocks and hands back to Claude as feedback:

.claude/hooks/block-dangerous-bash.sh
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')

if echo "$COMMAND" | grep -Eiq 'rm -rf (/|~)|drop (table|database)|git push (-f|--force)'; then
  echo "Blocked: this command matches a destructive pattern and needs a human to run it directly." >&2
  exit 2
fi

exit 0
.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous-bash.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

This is a best-effort net, not the enforcement boundary: a pattern match on the command string misses the same command written another way. A rule that must hold regardless of phrasing belongs in a deny rule in permission settings, which Claude Code evaluates whatever a PreToolUse hook returns and which blocks in every permission mode, bypassPermissions included. See Claude Code — Hooks reference (PreToolUse).

Run tests on Stop (a verification loop)

A Stop hook re-runs the project’s test command every time Claude is about to finish, and blocks the stop until it passes — reading stop_hook_active first so a test that can never pass doesn’t loop forever:

.claude/hooks/run-tests.sh
#!/bin/bash
INPUT=$(cat)

# Already continuing because of an earlier block on this turn -- let it stop.
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi

if ! OUTPUT=$(npm test 2>&1); then
  echo "Tests are failing. Fix them before stopping:" >&2
  echo "$OUTPUT" | tail -n 40 >&2
  exit 2
fi

exit 0
.claude/settings.json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

Claude Code overrides a Stop hook’s block after eight consecutive continuations on the same turn even without the stop_hook_active check, so a hook that ignores the field still can’t loop forever — checking it just means the loop ends on the first re-entry instead of the eighth (the cap itself can be raised with the CLAUDE_CODE_STOP_HOOK_BLOCK_CAP environment variable). This is the same mechanism Testing & Debugging with AI introduces as the deterministic version of "tests as the assistant’s success criterion" for an unattended run; the script here also reads stop_hook_active explicitly rather than relying only on the eight-continuation cap. See Claude Code — Hooks reference (Stop).

Inject context at SessionStart

A SessionStart hook with the compact matcher re-states project conventions right after a context compaction would otherwise have let them fade, by writing plain text to stdout:

.claude/settings.json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "git log --oneline -5 && echo 'Reminder: run npx antora antora-playbook.yml before claiming a page builds.'"
          }
        ]
      }
    ]
  }
}

With no matcher, the same hook fires on every SessionStart reason (startup, resume, clear, compact, fork), which is the right choice for context that should always be present rather than only after compaction. For static context that needs no script, both the hooks guide and the hooks reference recommend CLAUDE.md instead: it loads without running anything and is the standard place for unchanging project conventions. See Claude Code — Automate actions with hooks (Re-inject context after compaction) and Claude Code — Hooks reference (SessionStart).

Secret scanning before commit

Check Security (the detect-secrets-backed skill this account already ships) maintains a .secrets.baseline of already-triaged findings. A PreToolUse hook can then check every git commit Claude attempts against that baseline, instead of relying on Claude to invoke the skill first. It uses detect-secrets-hook, the detect-secrets entry point that exits non-zero only for secrets not in the baseline, on the files currently staged:

.claude/hooks/scan-before-commit.sh
#!/bin/bash
INPUT=$(cat)
cd "$(echo "$INPUT" | jq -r '.cwd')" || exit 2

if ! command -v detect-secrets-hook > /dev/null; then
  echo "detect-secrets is not installed; commit blocked until the secret scan can run." >&2
  exit 2
fi

if ! OUTPUT=$(git diff --cached --name-only -z | xargs -0 detect-secrets-hook --baseline .secrets.baseline 2>&1); then
  echo "detect-secrets found secrets that are not in .secrets.baseline; run /iru-check-security and triage them before committing:" >&2
  echo "$OUTPUT" | tail -n 20 >&2
  exit 2
fi

exit 0

The if field narrows the hook to Bash calls with a git commit subcommand (best effort, as the matcher section above explains). The settings in these recipes set "args": [] so the script path runs in exec form, with no shell to mis-split a ${CLAUDE_PROJECT_DIR} that contains spaces — the form the reference recommends for any hook that uses a path placeholder:

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git commit *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/scan-before-commit.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

The hook and the skill share the same tool and the same baseline. The skill is the interactive, triaged workflow: it rescans the repository, updates .secrets.baseline and reports only new findings. The hook is the net that catches a commit attempted without running the skill first. The two are complementary, and neither replaces the other. The hook only sees what is already staged when the tool call starts, so a single command such as git add . && git commit slips past it; a real pre-commit git hook closes that gap for every commit, not just Claude’s. See Claude Code — Hooks reference (the if field) and detect-secrets for detect-secrets-hook.

Why hooks give deterministic enforcement

An instruction in CLAUDE.md, a skill step, or even a direct request in the conversation is something Claude reads and decides whether to act on. It can be forgotten across a long session, deprioritized under competing instructions, or simply not apply because the model judged the situation didn’t call for it. A hook is not read by the model at all until after it has already run: the shell command executes unconditionally whenever its event and matcher fire, and its exit code or JSON decision is imposed on the turn regardless of what Claude would have chosen. That is the difference between asking a probabilistic model to comply and enforcing a rule outside it: a hook moves a check from "the model’s judgment" to "the same shell script, run the same way, every time" — the same reason Testing & Debugging with AI reaches for a Stop hook rather than a prompt reminder to keep an unattended session honest about whether tests actually pass.

sequenceDiagram participant U as User participant C as Claude participant H as Hook script participant T as Tool / OS U->>C: Prompt C->>H: UserPromptSubmit H-->>C: exit 0 / inject context C->>H: PreToolUse (about to run a tool) alt blocked H-->>C: exit 2 / permissionDecision deny C->>C: adjust, try again else allowed H-->>C: exit 0 C->>T: run tool T-->>C: result C->>H: PostToolUse H-->>C: exit 0 (e.g. formatter already ran) end C->>H: Stop (about to finish) alt verification fails H-->>C: exit 2, reason on stderr C->>C: another turn else verification passes H-->>U: response delivered end