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 |
|---|---|---|---|
|
Before a tool call runs. |
Yes |
Tool name ( |
|
After a tool call finishes successfully. |
No (the call already ran; exit |
Tool name |
|
After a tool call fails. |
No |
Tool name |
|
Before Claude processes a submitted prompt. |
Yes |
No matcher; fires on every prompt |
|
When Claude is about to finish responding. |
Yes |
No matcher; fires on every stop attempt |
|
When a subagent is about to finish. |
Yes |
Agent type ( |
|
When a session begins or resumes. |
No |
How it started ( |
|
When a session terminates. |
No |
Why it ended ( |
|
When Claude Code sends a desktop/UI notification, e.g. waiting on a permission prompt or gone idle. |
No |
Notification type ( |
|
Before context compaction. |
Yes (blocks the compaction) |
What triggered it ( |
|
After compaction completes. |
No |
Same values as |
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 |
|---|---|
|
No objection via the exit code. For |
|
Blocks the action on events that can block. The script’s stderr becomes the reason: fed back to Claude on
events like |
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:
-
PreToolUsenests its decision underhookSpecificOutput:{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Use rg instead of grep for better performance" } }permissionDecisionisallow(skip the interactive prompt; deny/ask rules still apply),deny(cancel the call and send the reason to Claude), orask(show the normal permission prompt). -
StopandSubagentStopuse a top-leveldecisionfield instead:{ "decision": "block", "reason": "Tests are still failing; keep working." }decision: "block"(or exit2) forces another turn and feedsreasonback to Claude as the next instruction; omitdecisionand the turn ends normally. -
UserPromptSubmitinjects text into context withadditionalContext, returned insidehookSpecificOutputalongsidehookEventName:{ "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 |
|---|---|---|
|
Every project for the current user |
No |
|
This project, everyone who checks it out |
Yes — commit it |
|
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 |
A plugin’s |
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.