Claude Code Hooks: Recipes, Exit Codes, and Safety

By Updated

What are Claude Code hooks?

Claude Code hooks are user-defined handlers (shell commands, HTTP endpoints, MCP tools, LLM prompts, subagents) that Claude Code runs at fixed points in its lifecycle: before and after a tool call, when a session starts and stops, before compaction. Unlike instructions in CLAUDE.md, a hook runs deterministically, and a PreToolUse block holds even in bypassPermissions mode.

TL;DR

  • -A hook can deny an action in two ways: exit 2 with the reason in stderr, or exit 0 with a JSON decision in stdout. Exit 1 without JSON counts as a non-blocking error, and the call goes through.
  • -A PreToolUse block (exit 2 or permissionDecision: deny) held in bypassPermissions mode in a live check on Claude Code 2.1.318. A hook timeout or crash doesn't block the call unless onFailure: "block" is set.
  • -A Stop hook that exits 2 makes Claude keep working. The hook must check stop_hook_active, otherwise Claude Code itself breaks the series after 8 consecutive continuations without a tool call.
  • -Plain-text stdout reaches Claude's context only on UserPromptSubmit, UserPromptExpansion, SessionStart, and PostModelSwitch. On every other event it goes to the debug log.
  • -Hooks run with the user's permissions. In claude -p mode there is no trust dialog, so hooks from someone else's .claude/settings.json run without asking.

An instruction in CLAUDE.md is a request: the model will likely follow it, but not always. Claude Code hooks are code that runs at a set point in the loop, every time. If a rule like “don’t touch .env” has to hold without exceptions, it belongs in a PreToolUse hook, not in the prompt.

The mechanism is simple: Claude Code passes the hook JSON on stdin, and the hook answers with an exit code and, optionally, JSON on stdout. The trouble is in the details. Exit 1 doesn’t block, a guard hook that times out lets the command through, a Stop hook without a flag check loops, and a JSON decision printed to stderr is ignored. None of these mistakes announces itself, and the hook looks fine until you need it.

This article covers how hooks work, which mechanism blocks on which event, and recipes for protection, automation, context, and notifications. A separate section deals with the security of the hooks themselves. Every fact here is checked against the Hooks reference and the Hooks guide as of November 6, 2026. The core mechanisms (PreToolUse blocking, the Stop gate, config reloading) went through live runs on Claude Code 2.1.318.

Where the recipes come from. The author’s user-level settings.json has 23 handlers on 17 events. Of these, 15 wrap one third-party app, and 2 call a third-party memory tool. The remaining 6 run the author’s own scripts: session-start orientation, an activity log, notifications. The recipes in the context and notification sections come from these.

The author’s permanent config has no guard PreToolUse hooks: protection is built on permissions and a git hook. The blocking, ConfigChange, and auto-approve recipes come from the docs. Their scripts were run on test input, and both guard recipes were also checked in a live run. The text marks the source of each one.

How Claude Code hooks work

Events and matchers

There are more than 30 events, and the list grows with almost every release, so check the reference table for the full set. About ten are enough for the recipes in this article:

EventWhen it firesMatcher filters onCan block
SessionStartstartup, resume, /clear, compactionstartup, resume, clear, compact, forkno
UserPromptSubmitprompt submitted-yes
PreToolUsebefore a tool calltool nameyes
PermissionRequestpermission prompt showntool namevia decision
PostToolUseafter a successful calltool nameno
NotificationClaude is waiting for input or permissionnotification typeno
StopClaude finished responding-yes (keep working)
SubagentStopa subagent finishedagent typeyes
PreCompactbefore compactionmanual, autoyes
ConfigChangea settings file or skill changeduser_settings, project_settings, and othersyes, except policy_settings

How a matcher is read depends on its characters. *, an empty string, or a missing field means “everything”. If the string contains only letters, digits, _, -, spaces, ,, and |, it’s an exact list: Edit|Write matches exactly two tools. Any other character turns the matcher into an unanchored JavaScript regex, so Edit.* also catches NotebookEdit. For an exact match you need ^Edit$. Matching is case-sensitive.

Configuration and where it lives

The configuration has three levels: the event, a group with a matcher, and a list of handlers.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh",
            "timeout": 5 }
        ]
      }
    ]
  }
}

Hooks are read from ~/.claude/settings.json, .claude/settings.json (committed to git), .claude/settings.local.json (not in git), managed policy, a plugin’s hooks/hooks.json, and the frontmatter of a skill or subagent. The same handler defined in several settings files runs once. All matching handlers for an event run in parallel.

There are five handler types: command, http, mcp_tool, prompt, and agent (the last one is marked experimental). Useful fields that guides often skip:

  • if - a filter in permission-rule syntax, for example "Bash(git *)" or "Edit(*.ts)". It only works on tool events (PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied). On any other event, a hook with if never runs.
  • args - when set, the command runs without a shell (exec form). Helps when quoting breaks.
  • onFailure - "continue" by default, or "block" (since v2.1.295). Without "block", a hook that crashes or hangs lets the call through.
  • timeout - in seconds. The default is 600 for command, http, and mcp_tool, 30 for prompt, and 60 for agent.

Input and environment variables

The hook gets JSON on stdin. Common fields: session_id, transcript_path, cwd, permission_mode, hook_event_name, plus a few internal ones. Tool events add tool_name, tool_input, and tool_use_id. UserPromptSubmit adds prompt. Stop gets stop_hook_active, last_assistant_message, background_tasks, and session_crons. A live run confirmed this set.

CLAUDE_PROJECT_DIR points to the project root, which makes it handy for referencing scripts. CLAUDE_ENV_FILE is available on SessionStart, Setup, CwdChanged, and FileChanged: lines appended to this file with >> become environment variables for the session. The session ID isn’t passed through the environment. The CLAUDE_SESSION_ID you see in third-party scripts isn’t documented, so it is safer to read session_id from stdin with jq.

Exit codes and JSON: how a hook answers

What each exit code means

What is in stdoutExit 0Exit 2Any other code (incl. 1)
Valid JSON objectsuccess, fields appliedblock; fields are read but don’t cancel the blockfields decide, the code is ignored
Malformed JSONnon-blocking error, the action proceedsblock, reason from stderrnon-blocking error
Text or nothingsuccessblock, reason from stderrnon-blocking error, the action proceeds

For a guard hook, the bottom-right cell matters most. A script that runs exit 1 on a problem out of Unix habit blocks nothing: Claude Code shows hook error with the first line of stderr and runs the call. The docs put it plainly: “If your hook is meant to enforce a policy, use exit 2.”

Claude doesn’t always see stdout on exit 0. Plain text is added to the context only on UserPromptSubmit, UserPromptExpansion, SessionStart, and PostModelSwitch. On other events it goes to the debug log. Claude never sees stderr on exit 0. Text that a hook adds to the context is capped at 10,000 characters: Claude Code saves the rest to a file and inserts the path with a preview.

What exit 2 blocks on which event

Exit 2 blocksExit 2 doesn’t block
PreToolUse - the call doesn’t runPostToolUse - the tool already ran, Claude sees stderr
UserPromptSubmit - the prompt doesn’t reach the modelPermissionRequest - the code is ignored, you need decision
Stop, SubagentStop - Claude keeps workingNotification, Setup, InstructionsLoaded - the code is ignored
PreCompact - compaction is cancelledSessionStart, SessionEnd, PostCompact - only the user sees stderr
ConfigChange - the change isn’t applied (except policy_settings)StopFailure - output and code are ignored
TaskCreated, TaskCompleted, TeammateIdle, PostToolBatchPostToolUseFailure, PermissionDenied

The full table for every event, including WorktreeCreate, Elicitation, and PreModelSwitch, is in Exit code 2 behavior per event.

Three ways to say “no”

  1. Exit 2 + stderr. The simplest option. Stderr becomes the reason, and Claude sees it as a tool error: PreToolUse:Write hook error: [<hook command>]: <your text>.
  2. Exit 0 + hookSpecificOutput.permissionDecision. PreToolUse only. Values are allow, deny, ask, and defer (the last one only in -p). Add updatedInput to rewrite the call’s arguments. The object is replaced as a whole, not merged.
  3. Exit 0 + top-level decision: "block" and reason. For UserPromptSubmit, PostToolUse, Stop, SubagentStop, ConfigChange, PreCompact, and a few other events. On PostToolUse it doesn’t undo the result: reason is simply placed next to the tool output.

In a live check on Claude Code 2.1.318 (claude -p mode, --permission-mode bypassPermissions), the first and second methods gave the same result: the file did not change, and the reason reached the model. With exit 2, Claude quoted the reason verbatim and did not try to get around the block through Bash. The docs advise against mixing methods: on exit 2, JSON fields are read but don’t cancel the block, and JSON printed to stderr counts as plain reason text.

Several hooks on one call

If several PreToolUse hooks return different decisions, the precedence is deny > defer > ask > allow. All hooks still run, and their side effects stay: a logging hook writes its line even if a sibling hook denied the call. Avoid two hooks that both return updatedInput for the same call, since order and merging aren’t guaranteed.

A hook returning allow doesn’t bypass permissions: deny and ask rules from settings are evaluated regardless of the hook’s answer. In the other direction, the hook is stronger than the mode: deny from PreToolUse held under bypassPermissions too.

Common format mistakes

  • Fields at the wrong level. permissionDecision or additionalContext at the top level of the JSON instead of inside hookSpecificOutput is silently ignored. Look for Hook JSON output had unrecognized keys in the debug log.
  • Echo in the shell profile. If ~/.zshrc or ~/.bashrc prints anything, the hook’s stdout no longer starts with {, and the JSON is read as text. The log shows Hook output does not start with {. The fix is to wrap profile output in if [[ $- == *i* ]]; then ... fi.
  • JSON built by string concatenation. A path with a quote breaks the object. Build it with jq -n --arg.
  • Exit code of the last line. If a script ends with [ condition ] && action, a false condition makes it return 1, and every call produces hook error. Put exit 0 on the last line.

Guard recipes (PreToolUse)

Every script in this section ran first on test stdin (echo '{...}' | ./hook.sh; echo $?), then live: claude -p --debug-file on Claude Code 2.1.318, in an isolated folder with project settings only. Both recipes blocked what they should and let everything else through. These hooks aren’t in the author’s permanent config.

Protecting files from edits

Matcher Edit|Write, config as in the example above. The script:

#!/bin/bash
P=$(jq -r '.tool_input.file_path // empty')
[ -z "$P" ] && exit 0
case "$P" in
  *.env|*/.env.*|*/.git/*|*/package-lock.json|*.pem|*.key)
    echo "Blocked: $P is on the protected paths list" >&2
    exit 2 ;;
esac
exit 0

On test input, /p/.env.local and /p/.env give exit 2, while src/a.ts and secrets_manager.py pass. The patterns are anchored to path boundaries on purpose: a substring like *secrets* would also block secrets_manager.py. The reason goes to stderr as plain text. A common mistake in examples online is printing JSON like {"decision":"deny"} to stderr on exit 2: it doesn’t become a decision.

Live run: the model tried to append a line to .env with Edit, the edit was refused, and the model quoted the stderr reason word for word. An edit to notes.txt went through. From the debug log:

Hooks: PreToolUse:Edit [...protect-files.sh] finished with status 2 (18ms)
Hook result has permissionBehavior=deny
Hook denied tool use for Edit

The hook covers only Edit and Write. It won’t see echo X > .env run through Bash. That needs a deny rule in permissions or a sandbox.

Bash guard: force-push and rm -rf

This one answers in JSON to show the second method. Matcher Bash, no if: with "if": "Bash(git push *)", the rm check would only run on commands that contain git push.

#!/bin/bash
CMD=$(jq -r '.tool_input.command // empty')
deny() {
  jq -n --arg r "$1" '{hookSpecificOutput: {hookEventName: "PreToolUse",
    permissionDecision: "deny", permissionDecisionReason: $r}}'
  exit 0
}
if grep -Eq 'git +push\b.*(--force\b|--force-with-lease|-f\b)' <<<"$CMD"; then
  deny "force-push is blocked by project policy"
fi
if grep -Eq '\brm +(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b' <<<"$CMD" &&
   ! grep -Eq '\brm +-[a-zA-Z]+ +/tmp/' <<<"$CMD"; then
  deny "rm -rf outside /tmp is blocked"
fi
exit 0

On test input, git push --force, git push -f, npm test && git push --force-with-lease, rm -rf ./build, and rm -fr src get deny. git push origin main and rm -rf /tmp/x pass.

Live, git push --force origin main and rm -rf victim were blocked, the folder stayed in place, and git status --short ran. The JSON hook logs status 0; the decision shows up one line below:

Hooks: PreToolUse:Bash [...guard-bash.sh] finished with status 0 (16ms)
Hook PreToolUse (...guard-bash.sh) returned permissionDecision: deny (reason: force-push is blocked by project policy)
Hook denied tool use for Bash

The same tests show why a guard like this isn’t a security boundary. It misses git push origin +main (force-push via + in the refspec), rm -r -f src (separate flags), and rm -rf /tmp/x && rm -rf src (the /tmp exception applied to the whole line). Variables, eval, and scripts in files are beyond what a regex can parse. The hook works well as a fast, readable refusal that explains itself to Claude. Hard policy belongs in permissions.deny and a sandbox.

Hook or permissions.deny

permissions.denyPreToolUse hook
What it checksa rule on the tool and its argumentsany logic over the input JSON
Message to Claudestandard refusalyour own reason in stderr or JSON
Behavior on failurenothing to fail, the rule is statictimeout and crash let the call through unless onFailure: "block" is set
Bypass via another tooldepends on the rulesthe hook sees only its own matcher
Works in bypassPermissionsyes, per the permission modes docsyes, verified on 2.1.318

The docs explicitly call the if field best-effort: if Claude Code can’t tell which commands Bash will run, the hook runs regardless of the pattern. For hard allow or deny, the docs recommend the permission system rather than a hook. The Claude Code guide explains how the rules work.

A Claude Code hook sees the text of a Bash command. The git reference-transaction hook sees the ref update itself, however it was triggered. In this blog’s repository, such a hook logs operations on refs/stash and blocks dropping a stash that contains untracked files (stash@{N}^3). The deliberate override is FORCE_STASH_DROP=1. The trigger was a real incident: in August 2026, git stash -u swept away the sources of four published articles. It isn’t a Claude Code hook, but the principle is the same: the rule is checked at a level the agent can’t get around by rephrasing the command.

Post-action recipes (PostToolUse, Stop)

Formatting after edits

The basic version from the docs is one line: jq -r '.tool_input.file_path' | xargs npx prettier --write on matcher Edit|Write. If the formatter should report errors, you need a script:

#!/bin/bash
F=$(jq -r '.tool_input.file_path // empty')
[ -z "$F" ] && exit 0
case "$F" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md) CMD=(npx prettier --write "$F") ;;
  *.py) CMD=(ruff format "$F") ;;
  *) exit 0 ;;
esac
if ! OUT=$("${CMD[@]}" 2>&1); then
  echo "Formatting $F failed: $OUT" >&2
  exit 2
fi
exit 0

Why exit 2: on PostToolUse it doesn’t roll back the edit (per the docs, “the tool already ran”), but stderr goes to Claude, and the model can fix the syntax. With exit 1, only the user sees the error, and with exit 0, nobody does. The script extends the docs one-liner and was only syntax-checked.

Stop gate: tests before stopping

#!/bin/bash
IN=$(cat)
[ "$(jq -r '.stop_hook_active // false' <<<"$IN")" = "true" ] && exit 0
if ! OUT=$(npm test --silent 2>&1); then
  echo "Tests are failing, fix them before finishing:" >&2
  tail -n 15 <<<"$OUT" >&2
  exit 2
fi
exit 0

A live run on 2.1.318 confirmed the mechanism with a simpler condition (a flag file instead of tests). The hook fired twice. The first call came with stop_hook_active: false: the hook returned exit 2, Claude received stderr, created the file, and responded again. The second call came with stop_hook_active: true: the hook returned 0, and the session ended.

Stop gate pitfalls:

  • Stop fires after every Claude response, even a short clarification, and doesn’t fire when the user interrupts. A heavy npm test on every response is expensive, so it makes sense to check whether any files changed.
  • Without the stop_hook_active check, a hook with an unmeetable condition loops. Claude Code overrides the block after 8 consecutive continuations (the cap is set by the CLAUDE_CODE_STOP_HOOK_BLOCK_CAP variable), but the counter resets on every tool call. An agent fixing tests calls tools constantly, so the cap may never kick in.
  • The stop_hook_active check gives exactly one retry: if tests are still red, the second Stop goes through. For more attempts, keep a counter in a file keyed by session_id.
  • onFailure: "block" has no effect on Stop: Claude can’t fix a hook that doesn’t run.

The no-script alternative is type: "prompt": the model returns {"ok": false, "reason": "..."}, and Claude keeps working. That’s an LLM call on every Stop, with its own cost and a 30-second timeout. The built-in /goal command is the same thing packaged as a ready-made Stop hook for the session.

Activity log

The author keeps this hook in the permanent config. PostToolUse writes to JSONL which files were read and edited, so the next session knows the context. In the original version, the tool filter sits inside the script, and the JSON line is glued together from variables in echo. Below is the fixed version: the filter moved to matcher Read|Edit|Write|Grep|Glob, the line is built with jq -nc, and the timeout is 3 seconds.

#!/bin/bash
set -euo pipefail
IN=$(cat)
TOOL=$(jq -r '.tool_name // empty' <<<"$IN")
FILE=$(jq -r '.tool_input.file_path // .tool_input.pattern // empty' <<<"$IN")
[ -z "$FILE" ] && exit 0
LOG="$CLAUDE_PROJECT_DIR/.claude/activity.jsonl"
jq -nc --arg ts "$(date -u +%FT%TZ)" --arg tool "$TOOL" --arg file "${FILE#"$PWD"/}" \
  '{ts: $ts, tool: $tool, file: $file}' >> "$LOG"
if [ "$(wc -l < "$LOG")" -gt 1000 ]; then
  tail -n 500 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
fi
exit 0

A path with a quote (src/a "q".ts) was written correctly on test input, while string concatenation produces invalid JSON on it. Rotation keeps the file under a thousand lines. Add the file to .gitignore.

Context and state

Orientation at session start

SessionStart is one of the four events where plain-text stdout enters the context. The author uses it so Claude knows the branch, recent commits, and uncommitted files from the first message:

#!/bin/bash
git rev-parse --is-inside-work-tree &>/dev/null || exit 0
echo "Branch: $(git branch --show-current 2>/dev/null || echo detached)"
echo "Recent commits:"; git log --oneline -5
CHANGES=$(git status --porcelain | head -15)
if [ -n "$CHANGES" ]; then echo "Uncommitted changes:"; echo "$CHANGES"; fi
exit 0

The config sets "timeout": 5, and outside a git repository the hook stays silent. A second SessionStart hook reads the log from the previous recipe, so the two hooks connect through a file: one writes, the other reads.

#!/bin/bash
LOG="$CLAUDE_PROJECT_DIR/.claude/activity.jsonl"
[ -f "$LOG" ] || exit 0
SINCE=$(date -u -v-7d +%FT%TZ 2>/dev/null || date -u -d '7 days ago' +%FT%TZ)
echo "Most touched files (7d):"
jq -r --arg s "$SINCE" 'select(.ts >= $s) | .file' "$LOG" |
  sort | uniq -c | sort -rn | head -15

Keep the output short: everything the hook prints costs context in every session. The article on context engineering covers the balance between useful and excess context.

Re-injecting context after compaction

Compaction compresses the conversation, and details that existed only in the chat get lost. The root CLAUDE.md is re-read from disk after /compact (more in the CLAUDE.md guide). For dynamic context, the docs suggest SessionStart with matcher compact:

{ "hooks": { "SessionStart": [ { "matcher": "compact", "hooks": [
  { "type": "command",
    "command": "echo 'Reminder: use Bun, not npm. Current sprint: auth refactor.'; git log --oneline -5" }
] } ] } }

You can pair it with PreCompact: that hook saves the task state to a file before compaction, and SessionStart with compact prints it afterwards. PreCompact’s plain-text stdout becomes instructions for the compaction summary itself. This recipe comes from the docs and was not tested in a live session.

Notifications without spam

The basic docs example is osascript -e 'display notification ...' on the Notification event. On macOS it fails silently until Script Editor has notification permission. Run osascript -e 'display notification "test"' in a terminal once, then enable Script Editor in System Settings > Notifications.

If you attach a notification to both Notification and Stop, you often get two notifications in a row at the end of a turn. The author solves this with a per-session lock file: the notification is sent once, and a UserPromptSubmit hook clears the lock. The original version took the key from CLAUDE_SESSION_ID, which isn’t in the docs (the $PPID fallback was most likely what fired), and inserted the text into AppleScript without escaping. The fixed version reads session_id from stdin and passes the text through argv:

#!/bin/bash
# notify-once.sh "<text>" - on Notification and Stop events
SID=$(jq -r '.session_id // "unknown"')
LOCK="${TMPDIR:-/tmp}/claude-notified-$SID"
[ -f "$LOCK" ] && exit 0
touch "$LOCK"
osascript -e 'on run argv' \
  -e 'display notification (item 1 of argv) with title "Claude Code"' \
  -e 'end run' "$1"

The reset on UserPromptSubmit is one line: rm -f "${TMPDIR:-/tmp}/claude-notified-$(jq -r '.session_id')". Passing the text through argv matters even if it’s constant today: as soon as a file name or a piece of the response ends up in the notification, string substitution into AppleScript becomes an injection.

Audit and policy

Auditing config changes. ConfigChange fires when a settings file or skill changes during a session. The docs example on matcher user_settings|project_settings|skills:

jq -c '{timestamp: now | todate, source: .source, file: .file_path}' >> ~/claude-config-audit.log

Exit 2 or decision: "block" keeps the change from taking effect, except for the policy_settings source: managed settings always apply. The block is silent. Neither the user nor Claude sees a message, only a line in the debug log.

Narrow auto-approve. A PermissionRequest hook can answer a permission prompt in place of a human. The docs example removes the dialog only for ExitPlanMode:

{ "hooks": { "PermissionRequest": [ { "matcher": "ExitPlanMode", "hooks": [
  { "type": "command",
    "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'" }
] } ] } }

Exit 2 doesn’t work on this event; the decision goes only through decision.behavior. Keep the matcher narrow: * here turns the hook into an automatic “yes” to everything. Such a hook doesn’t bypass deny rules from permissions. Both recipes come from the docs and were checked only on test input.

Team policy. In managed settings, allowManagedHooksOnly keeps only the administrator’s hooks, disableAllHooks in user and project settings doesn’t turn them off, and allowedHttpHookUrls and httpHookAllowedEnvVars limit where HTTP hooks can send data and which variables they can put into headers.

Securing the hooks themselves

A command hook runs shell commands with the user’s full permissions: it reads, changes, and deletes anything the user can access. The docs give this warning its own block, and several rules follow from it.

Folder trust. In an interactive session, Claude Code doesn’t run hooks from any settings file, the user one included, until the folder trust dialog is accepted. In claude -p mode and in the SDK there is no dialog: the folder is treated as trusted, and hooks from .claude/settings.json in someone else’s repository run immediately. Before a scripted -p run on someone else’s code, read .claude/, use --bare, or disable hooks for the run with --settings '{"disableAllHooks": true}'.

Input data. The model builds tool_input, and the model may have read anything, including a prompt injection in a file or an MCP server response (more on these attacks). Always quote variables ("$P"), no eval and no sh -c on command content, check paths for .., use absolute paths to scripts.

Failure modes. By default, a guard hook fails open:

SituationWhat happens to the callHow to close it
Hook exits with 1goes throughexit 2 or JSON deny
PreToolUse hook times outgoes throughshort timeout + onFailure: "block"
Script not found or failed to startgoes throughonFailure: "block"
No jqjq fails, the variable is empty, the script exits 0check command -v jq at the start, onFailure: "block"
Windows without Git BashPreToolUse and PermissionRequest hooks block on any code other than 0 and 2explicit shell and onFailure

With onFailure: "block" (v2.1.295 and later), a timeout, crash, missing script, or invalid JSON does the same as exit 2 on that event. The log line looks like failed; blocking because onFailure is "block". Test degradation explicitly: remove jq from PATH, set a sleep longer than the timeout, and see what happens to the call.

Clean stdout and logs. The shell profile should print nothing in non-interactive mode. Hook logs should not contain command content with tokens, and log files need rotation.

Hooks don’t replace permissions and a sandbox. A hook sees one matcher and one call, a regex over command text can be bypassed, and if is best-effort. A hook complements static rules well, but doesn’t stand in for them.

Debugging

  • /hooks - lists all configured hooks, read-only.
  • claude --debug-file /tmp/claude.log - writes the hook execution log to a known file; follow it with tail -f. Without the flag: claude --debug (log in ~/.claude/debug/<session-id>.txt) or /debug mid-session.
  • Ctrl+O - transcript view: blocking errors with their reason and non-blocking hook error entries.
  • Test without Claude Code: echo '{"tool_input":{"file_path":"/p/.env"}}' | ./protect-files.sh; echo $?.

Five lines in the debug log are worth knowing. Hook output does not start with { means the output was read as text: normal for empty output with exit 0, a format error if you meant to return JSON. Hook JSON output had unrecognized keys points to fields at the wrong level. Lines like Hooks: PostToolUse:Write [...] finished with status 0 (31ms) (since v2.1.296) show each hook’s exit code and duration, which is how you find slow ones. But status 0 doesn’t mean “allowed”: a JSON hook denies with exit 0, and its decision appears in the line returned permissionDecision: deny (reason: ...). timed out after <N>ms means a timeout.

You usually don’t need to restart after editing hooks. In a live check, the model wrote a new PreToolUse hook into .claude/settings.json mid-session, the debug log showed Settings changed from projectSettings, updating app state, and the next Write was already blocked. The docs note that the watcher occasionally misses changes; a restart helps then. Plugin hooks and edits to ~/.claude/settings.json were not tested in this check.

Timeouts by role

The 600-second default means a hung command hook without an explicit timeout can hold the session for ten minutes. The timeout is part of the hook’s contract, so set it for each role:

Hook roleEventTimeoutSource
Activity logPostToolUse3 sauthor’s config
Orientation and contextSessionStart5 sauthor’s config
GuardPreToolUse5 s + onFailure: "block"recommendation based on docs
Saving memoryStop30 sauthor’s config
Saving statePreCompact60 sauthor’s config
Code review before stoppingStop900 sa hook from one plugin
Prompt checkUserPromptSubmit30 s by defaultdocs
FinalizationSessionEnd1.5 s total budget, up to 60 sdocs

The shorter UserPromptSubmit default is deliberate: the hook runs before every prompt, and the model doesn’t start working until it finishes. SessionEnd splits a total budget of 1.5 seconds across all hooks; the budget grows up to 60 seconds if a larger timeout is set in settings.

Hook, skill, or CLAUDE.md

TaskMechanismWhy
Block edits to .envpermissions.deny + PreToolUse hookdeterministic, with a clear reason
Format after every editPostToolUse hookalways happens, without the model
Don’t finish with red testsStop hookthe model can’t “forget” the check
Code conventions and architectureCLAUDE.mdcontext, not a mechanical rule
Release procedure, review checklistSkillloaded per task, doesn’t occupy context permanently
Isolated subtask with narrow permissionsSubagentits own context and toolset

Hooks can also be declared in the frontmatter of a skill or subagent: then they apply only while that component is running. The article on Claude Code subagents shows how this works for subagents and the SubagentStart and SubagentStop events. Skills, agents, and memory get their own separate guide.

Takeaways

  1. Block with exit 2 and stderr, or with exit 0 and JSON in stdout. exit 1 doesn’t block.
  2. Give every guard hook a short timeout and onFailure: "block", otherwise a timeout or crash lets the call through.
  3. In a Stop hook, check stop_hook_active on the first line.
  4. Build JSON with jq -n --arg, test the hook on sample stdin before wiring it up, and check --debug-file afterwards.
  5. A hook complements permissions and a sandbox but doesn’t replace them. A regex over command text is a convenient refusal with an explanation, not a security boundary.

Frequently Asked Questions

What is the difference between exit 2 and JSON permissionDecision: deny?
On PreToolUse the effect is the same: the call doesn't run and Claude sees the reason. Exit 2 is simpler, you write the reason as plain text to stderr. JSON goes to stdout with exit 0 and gives more options: allow, ask, defer, and rewriting arguments through updatedInput. JSON written to stderr with exit 2 isn't a decision, just the text of the reason.
Does a hook block in --dangerously-skip-permissions mode?
Yes. In a live check on Claude Code 2.1.318, both exit 2 and JSON deny blocked Write in bypassPermissions mode. The reverse doesn't work: a hook that returns allow doesn't bypass deny rules from permissions, which are evaluated regardless of the hook's answer.
Why does my Stop hook loop?
Stop fires after every Claude response, and exit 2 on it means "keep working". If the condition can't be met, the hook blocks again and again. The fix is to exit 0 when stop_hook_active in the input JSON is true. Claude Code caps the series at 8 consecutive continuations, but the counter resets on every tool call.
Do I need to restart Claude Code after editing hooks?
Usually not. A file watcher picks up changes to settings files. In a live check, a hook added to .claude/settings.json mid-session fired on the very next call. If changes don't show up within a few seconds, the docs recommend restarting the session.
Should I use a hook, a skill, or CLAUDE.md?
CLAUDE.md is for instructions the model applies at its own discretion. A skill is for procedures and knowledge loaded per task. A hook is for anything that must happen every time without the model's involvement: blocking, formatting, logging, notifications.
Is it safe to open someone else's project that has a .claude/settings.json?
In an interactive session, Claude Code doesn't run hooks until you accept the folder trust dialog. In claude -p mode and in the SDK there is no dialog, and hooks from the repository run immediately. Before running -p on someone else's code, read .claude/ or pass --settings '{"disableAllHooks": true}'.