Magic Tools
Claude GuidesBy CooconApril 13, 2026601 views13 min read

Claude Code Hooks: Custom Automation Workflows

This article originally covered only the concepts. On 2026-09-19 I added the "Hands-On Lab" section at the end: a fully isolated hook laboratory on Claude Code 2.1.270, with all 8 events wired to the same capture script, every stdin JSON dumped to disk, and a hook-marker-file plus tool-marker-file pair to confirm independently whether the tool actually ran. The headline: three things in the body below are wrong — only exit 2 blocks a tool (exit 1 and exit 3 both let it through), the singular "hook": {…} config key is silently ignored, and neither $CLAUDE_FILE_PATH nor $CLAUDE_TOOL_INPUT exists in 2.1.270. The original text is left untouched; the lab section below gives the real readings and the corrected patterns.

What Are Hooks

Hooks are Claude Code's automation extension mechanism. They let you run custom scripts automatically when specific events occur — before or after Claude calls a tool. Use hooks to auto-format code, block dangerous commands, or send notifications.

Hooks execute deterministically on your local machine without consuming LLM tokens, making them ideal for building reliable automation workflows.

Hook Event Types

Event When It Fires
PreToolUse Before a tool call executes
PostToolUse After a tool call completes
Notification When Claude sends a notification
Stop When Claude finishes a response

Configuration

Configure hooks in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hook": {
          "type": "command",
          "command": "echo 'File is about to be modified'"
        }
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hook": {
          "type": "command",
          "command": "npx prettier --write $CLAUDE_FILE_PATH"
        }
      }
    ]
  }
}

The matcher field supports regex to match tool names like Edit, Write, or Bash.

Practical Examples

Auto-Lint After File Save

{
  "PostToolUse": [
    {
      "matcher": "Edit|Write",
      "hook": {
        "type": "command",
        "command": "npx eslint --fix $CLAUDE_FILE_PATH 2>/dev/null || true"
      }
    }
  ]
}

Block Dangerous Commands

{
  "PreToolUse": [
    {
      "matcher": "Bash",
      "hook": {
        "type": "command",
        "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qE 'rm\\s+-rf\\s+/|DROP\\s+DATABASE'; then echo 'BLOCKED: Dangerous command detected' >&2; exit 1; fi"
      }
    }
  ]
}

When a hook script exits with a non-zero status, the corresponding tool call is blocked.

Send Notification on Completion

{
  "Stop": [
    {
      "matcher": "",
      "hook": {
        "type": "command",
        "command": "osascript -e 'display notification \"Claude finished the task\" with title \"Claude Code\"'"
      }
    }
  ]
}

Best Practices

  • Keep scripts fast: Hooks block Claude's execution, so avoid long-running tasks
  • Handle errors gracefully: Use || true to prevent non-critical failures from blocking the workflow
  • Leverage environment variables: Claude injects $CLAUDE_FILE_PATH, $CLAUDE_TOOL_INPUT, and other context variables
  • Share with your team: Put hooks in the project-level .claude/settings.json and commit to the repository

FAQ

What is the difference between Hooks and MCP?

Hooks are deterministic local scripts that trigger on specific events, ideal for automated checks and formatting. MCP is a protocol that lets Claude access external tools and data sources on demand. They are complementary — hooks for automation rules, MCP for extended capabilities.

What happens if a hook script fails?

If a PreToolUse hook exits with a non-zero status, the tool call is blocked. If a PostToolUse hook fails, Claude receives the error but continues with subsequent operations.

What environment variables are available in hooks?

Claude Code automatically injects context variables including CLAUDE_FILE_PATH (the file being operated on) and CLAUDE_TOOL_INPUT (the tool's input parameters). Available variables depend on the event type and tool being used.


Hands-On Lab: All 8 Events, Exit Codes, Matchers and Latency on 2.1.270

The problem

The body above follows the official docs. Plenty of people get it working; plenty of people don't — and "it doesn't work" is unusually hard to debug with hooks: a malformed config doesn't error, a hook that never fires doesn't error, a wrong exit code doesn't error. All you see is "it seems to do nothing."

I fell into one of those traps before; that write-up lives separately in The stdin trap in Claude Code hooks (that one is about how a hook script reads its own stdin — no overlap with this piece). This time I wanted to measure the whole external contract: which events actually fire, what JSON the hook really receives, what each exit code means, how to write a matcher that hits, and what a hook costs in latency.

Analysis

Turning those questions into real readings runs into three obstacles:

  1. "The hook never fired" and "the hook fired but didn't do anything" look identical in logs. A hook's own log can't tell you whether PreToolUse skipped or ran-and-failed-to-block.
  2. This machine already runs two production hooks (a global ~/.claude/hooks/log_tool_io.py and the repo's .claude/hooks/lessons-inbox-capture.sh). The experiment must not touch or pollute either.
  3. Every cold claude -p burns ~50K cache-creation tokens — about $0.32 a session on Opus. Dozens of runs gets expensive fast.

Approach and trade-offs

Final design: a dedicated CLAUDE_CONFIG_DIR + --settings <absolute path> + one work directory per run + a Haiku driver + double marker files.

  • Isolate with --settings, not .claude/settings.json: --settings takes an arbitrary path, so the lab config never lands in a scanned directory; CLAUDE_CONFIG_DIR points at an empty tmp/ directory so user-level hooks can't leak in either. (Rejected: editing the repo's real .claude/settings.json — pollutes production hooks and needs a manual revert every time.)
  • Double marker files are the key idea: the hook appends a line to hook-ran.txt on every fire, and the Bash command the model runs writes tool-ran.txt. Cross the two and "how many times the hook fired" and "whether the tool actually executed" become two independent facts, no guessing. (Rejected: trusting the model's prose — it will happily claim "I ran it.")
  • Haiku 4.5 as the driver: 38 sessions for $1.5248, about $0.04 each. (Rejected: hitting /v1/messages directly to synthesize hook scenarios — the local Anthropic proxy only admits Claude Code clients and 503s any script, so that road is closed.)
  • --output-format json for time and cost: duration_ms, num_turns and total_cost_usd come from the CLI itself, no stopwatch. End-to-end wall_s is recorded separately in the shell and read alongside.

Every run has the same shape:

claude -p "<minimal task>" \
  --settings /abs/path/to/lab/xxx.settings.json \
  --output-format json \
  < /dev/null          # without this it waits 3s for stdin — ruins the latency numbers

Experiment 1: which of the 8 events actually fire

A minimal task (Read a file → Write a file → echo lab-probe), with all 8 events wired to the same capture.sh that appends the raw stdin JSON to events.jsonl:

Timeline of all 8 Claude Code hook events measured on this machine

The main session fires 9 times, in this order (timestamps relative to the first fire):

Time Event tool_name
0.000s SessionStart —
0.024s UserPromptSubmit —
6.095s PreToolUse Read
6.122s PostToolUse Read
9.374s PreToolUse Write
9.400s PostToolUse Write
9.450s PreToolUse Bash
9.573s PostToolUse Bash
11.933s Stop —

Note the PreToolUse → PostToolUse gaps: 26–123ms, which is the tool's real execution time. The gap between tool calls is 3.2 seconds — that's the model thinking. Hook overhead has to be compared against "model thinking," not against "tool execution" — the latency experiment below confirms this.

The remaining three events need separate sessions. Asking the model to spawn a background subagent:

Time Event Note
8.992s PreToolUse tool_name is Agent, not the Task you often see in docs
11.164s Stop the main session finished first
13.234s SubagentStop agent_type=general-purpose
13.344s UserPromptSubmit prompt body is <task-notification>… — machine-generated prompts go through this event too
16.663s Stop second Stop

Neither of those is in the docs. If you want a matcher for subagent calls, write Agent. If you do "user input screening" in UserPromptSubmit, be ready to receive prompts no human typed — otherwise your own rule blocks every returning background task.

Then --resume a session and run /compact manually:

Time Event Note
0.000s SessionStart source=resume
0.028s PreCompact trigger=manual
14.928s SubagentStop agent_type="" — compaction runs as an anonymous subagent
14.954s SessionStart source=compact

Compaction emits an extra SubagentStop and an extra SessionStart. If your SessionStart hook does heavy work (injecting project context, say), it runs many times in a long session, not once.

Notification did not fire on this machine. Two attempts came back empty: one asking the model to run a command needing approval (5 events, no Notification), one letting it idle (3 events, no Notification). The reason is plain — claude -p is headless; there is nobody to notify, which is exactly what the CLAUDE_CODE_SESSION_ATTENDED environment variable is about. The following is a guess, not verified: Notification most likely fires only in an interactive TTY session, when waiting for permission or idling; testing it probably requires a real terminal session driven by hand. I could not reproduce it headless, so I'm not inventing numbers.

Experiment 2: the stdin fields a hook really receives

This table is read out of the dumped events.jsonl, not copied from docs. Every event carries session_id, transcript_path, cwd and hook_event_name; the differences:

Event Event-specific fields (measured on 2.1.270)
SessionStart source (startup / resume / compact)
UserPromptSubmit prompt_id, permission_mode, prompt
PreToolUse prompt_id, permission_mode, tool_name, tool_input, tool_use_id
PostToolUse the same plus tool_response (for Read: {type, file:{filePath, content, numLines, totalLines}})
Stop stop_hook_active, last_assistant_message, background_tasks, session_crons
SubagentStop agent_id, agent_type, agent_transcript_path, stop_hook_active, last_assistant_message
PreCompact trigger (manual / auto), custom_instructions
Notification did not fire here — no measured fields

The environment-variable claim in the body above is wrong. The CLAUDE* variables actually present in the hook process are:

CLAUDECODE, CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR, CLAUDE_CODE_ATTRIBUTION_HEADER,
CLAUDE_CODE_CHILD_SESSION, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, CLAUDE_CODE_ENTRYPOINT,
CLAUDE_CODE_EXECPATH, CLAUDE_CODE_MAX_OUTPUT_TOKENS, CLAUDE_CODE_MESSAGING_SOCKET,
CLAUDE_CODE_MESSAGING_TOKEN, CLAUDE_CODE_SESSION_ATTENDED, CLAUDE_CODE_SESSION_ID,
CLAUDE_CODE_USE_VERTEX, CLAUDE_CONFIG_DIR, CLAUDE_EFFORT, CLAUDE_PID, CLAUDE_PROJECT_DIR

CLAUDE_FILE_PATH and CLAUDE_TOOL_INPUT are both UNSET. Take the path out of the stdin JSON instead:

# ❌ the pattern in the body: both variables are empty on 2.1.270,
#    so prettier formats an empty path
npx prettier --write $CLAUDE_FILE_PATH

# ✅ measured working: read tool_input.file_path from stdin JSON
payload=$(cat)
f=$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty')
[ -n "$f" ] && npx prettier --write "$f"

Also: CLAUDE_PROJECT_DIR measures as the cwd where claude was launched, not the git root. Start a session in a subdirectory and any script path built from it points at the wrong place.

Experiment 3: what exit codes actually mean

This is the worst error in the body above, which says "when a hook script exits with a non-zero status, the corresponding tool call is blocked." Measured: it isn't.

Hook exit-code semantics confirmed with double marker files

One isolated session per exit code; the hook writes hook-ran.txt, the Bash tool writes tool-ran.txt:

Config tool-ran.txt hook fires turns cost what the model saw
PreToolUse exit 0 YES 1 2 $0.0695 normal result
PreToolUse exit 1 YES 1 2 $0.0405 normal result; stderr never reached the context
PreToolUse exit 2 NO 2 3 $0.0496 tool_result with is_error=true
PreToolUse exit 3 YES 1 2 $0.0409 normal result
UserPromptSubmit exit 1 YES 1 2 $0.0409 normal result
UserPromptSubmit exit 2 NO 1 0 $0 whole turn aborted; the model was never called
Stop exit 2 — 3 3 $0.0797 forced to continue for 2 extra turns

Only exit 2 has blocking semantics. On PreToolUse, exit 1 and exit 3 are exactly equivalent to allowing the call, and the hook's stderr doesn't even reach the model's context — you think you blocked a dangerous command; it ran.

The marker files aren't the only evidence. The session transcript holds two real tool_result entries:

is_error=True | PreToolUse:Bash hook error: [.../exitcode.sh PreToolUse-exit2 2 0]:
               HOOKSTDERR-PreToolUse-exit2-fire1
is_error=True | PreToolUse:Bash hook error: [.../exitcode.sh PreToolUse-exit2 2 0]:
               HOOKSTDERR-PreToolUse-exit2-fire2     ← the model retried and got blocked again

Two details: the hook's stderr goes into the model's context verbatim (so write the rejection reason for the model to read), and a blocked model retries, which is why the hook fired twice instead of once — make your hook idempotent, don't bill or notify from the blocking branch.

The UserPromptSubmit exit 2 row has the best numbers: {"wall_s": 0.195, "duration_ms": 31, "num_turns": 0, "total_cost_usd": 0}. It aborts before the model is ever called: 31 milliseconds, zero cost. Input-side compliance checks (rejecting prompts containing secrets, say) belong here, not in PreToolUse.

Stop exit 2 drags the model back to work. My script carried a max-fires=2 safety valve — the third fire returns 0, which is the only reason it stopped. Without the valve it's an infinite loop. The stop_hook_active field in the stdin JSON exists for exactly this: it flips to true on re-entry. Use it instead of my counter file.

Experiment 4: ten matcher spellings, which ones hit

Ten matchers mounted side by side under a single PreToolUse; one session calling Read, Write and Bash once each:

Ten matcher spellings tested for real hits

Matcher Actually hit
"*" Read, Write, Bash
matcher field omitted Read, Write, Bash
"" (empty string) Read, Write, Bash
"Bash" Bash
"Edit|Write" Write
"^Read$" Read
"Read,Write" Read, Write
"Rea" (prefix) no fire
"bash" (lowercase) no fire
"Notebook.*" no fire (no Notebook tool in this session)

Three conclusions:

  1. "*", "" and omitting the field are equivalent in practice — all match everything.
  2. Matching is whole-name, not substring — "Rea" does not match Read, which means the anchors in "^Read$" are redundant. Regex really does work (| fires), but don't expect substrings.
  3. It is case-sensitive: "bash" does not match Bash.

Also, putting "matcher": "Bash" on the Stop event — it fires anyway. Matchers on non-tool events are ignored outright: no effect, no error, no filtering.

Finally, the config format. The body above (and many tutorials) uses the singular "hook": {…}:

hook fires: 0    tool executed: YES    stderr: (empty, no error at all)

2.1.270 only accepts the plural array "hooks": [{…}]. The singular form neither errors nor works — the hardest kind of failure, as noted at the top. The correct shape:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "/abs/path/check.sh", "timeout": 5 }
        ]
      }
    ]
  }
}

Experiment 5: what a hook costs in latency

Same task (Read + Write + Bash — 9 hook fires per session) × 3 configurations × 3 runs, medians:

Hook latency cost, parallelism and fail-open timeout

Configuration 3 runs, wall_s Median API duration_ms median
no hook 9.577 / 10.267 / 11.553 10.267s 10.093s
empty hook (exit 0) 9.014 / 9.540 / 10.590 9.540s 9.355s
sleep 0.5 hook 13.831 / 13.871 / 16.742 13.871s 13.007s

The empty-hook median is 0.7s lower than no hook at all — which doesn't mean hooks make things faster; it means the cost of 9 empty hooks is entirely inside the noise band of model response time. The spread across 3 runs is already 2 seconds. The only measurable effect is the sleep 0.5 group: 9 × 0.5s = 4.5s of theoretical overhead pushed the median up by 3.6s.

So: don't fear adding hooks, fear doing slow work inside them. A jq check or a grep is nothing next to a 3-second thinking pause; an npx eslint cold start (1–2s measured) multiplied by every Edit is very much something.

Two hooks on the same event run in parallel:

A start 1789784312.065773000
B start 1789784312.065783000
A end   1789784313.229337000
B end   1789784313.229344000

A and B start 0.010 milliseconds apart and each take 1.164s — two hooks that each sleep 1 cost 1.164s total, not 2s.

Timeout behaviour is fail-open. With "timeout": 2 and a sleep 10 in the hook:

SLOW start 1789784541.…       ← start only, no end line: the process was killed
POST start 1789784543.…       ← PostToolUse fired 2.143s later

tool-ran.txt exists — a timed-out PreToolUse does not block; the tool runs. If your security check is a script that can get slow (fetching a policy over the network, say), the day it gets slow is the day it stops protecting you.

Results

The one-line version, ready to paste into your config:

  • The config key is "hooks": [...] (plural array); singular "hook": {...} fails silently.
  • To block, the exit code must be 2; 1 and 3 both allow, and stderr never reaches the model.
  • stderr is written for the model — phrase it as an actionable instruction ("use X instead of Y"), not BLOCKED.
  • A model blocked by exit 2 retries, so hooks must be idempotent.
  • Get the file path via jq -r '.tool_input.file_path', not $CLAUDE_FILE_PATH (doesn't exist).
  • Put input-side checks on UserPromptSubmit: exit 2 aborts in 31ms with 0 turns and $0 — an order of magnitude cheaper than blocking at PreToolUse.
  • When a Stop hook forces continuation with exit 2, read stop_hook_active or you get an infinite loop.
  • Matchers are case-sensitive and whole-name; "*", "" and omission are equivalent; matchers on non-tool events are ignored.
  • The subagent tool is named Agent; UserPromptSubmit also receives machine-generated <task-notification> prompts.
  • Don't fear adding hooks (empty ones are unmeasurable), fear slow work inside them; timeouts are fail-open, so don't build a security check that can get slow.

All readings come from 38 claude -p sessions costing $1.5248 in total; the raw JSONL and settings files are kept locally.

Pitfalls

Pitfall 1: "hook" vs "hooks" — one letter, silent failure. Symptom: a config copied from an older tutorial does nothing; 0 hook fires, tool runs normally, stderr empty. Root cause: the 2.1.270 schema is {"matcher": ..., "hooks": [ ... ]}; the singular "hook" is an unknown key, ignored without validation. Fix: use the plural array — and when debugging any "hook doesn't work" problem, first mount a log-only exit 0 hook to prove the trigger path works at all, then debug your logic.

Pitfall 2: --settings only accepts absolute paths. Symptom: --settings lab/matrix.settings.json reports Error: Settings file not found even though the file is right there. Root cause: relative paths resolve against the session's working directory, and every run of mine used its own work directory. Fix: always use absolute paths for --settings and for hook command entries.

Pitfall 3: headless runs waste 3 seconds waiting for stdin. Symptom: claude -p prints Warning: no stdin data received in 3s before doing anything. For a latency experiment those 3 seconds destroy every reading. Fix: claude -p "..." < /dev/null. This applies to any scripted invocation, not just experiments.

Pitfall 4: a PostToolUse hook that modifies the file triggers a system reminder. An accidental finding. Have a PostToolUse hook append to the file right after the model writes it (simulating a formatter) and the transcript grows a hook_additional_context attachment:

PostToolUse hook modified …/note.txt after your edit (likely a formatter). Your next Edit will not fail with a stale-file error, but if its old_string targets a region the hook reformatted, Read the file first.

So Claude Code knows formatter-style hooks exist and warns the model proactively. The good news: auto-formatting won't break the model's subsequent edits. The price: every such hook injects another paragraph into the context.

Related Articles

Blocking rm -rf in Claude Code: A Deny Rule Stopped 14 Deletes in Two Weeks, 7 Other Spellings Still Get Through, and a Hook That Closes the Gap

To keep Claude Code from deleting things by accident, I added a deny rule, Bash(rm -rf *), to my global settings. Across my sessions from October 7 to 21 it blocked 14 deletes. After each denial the model picked a new directory name 8 times and dropped the delete 6 times. It never tried another spelling. Then I replayed 22 delete commands verbatim against Claude Code 2.1.285 through a stub. rm -fr, rm -r -f, rm -Rf and rm -r deleted the directory in manual, auto and bypass mode alike. /bin/rm, find -delete and bash -c deleted it in manual and bypass mode. In auto mode, rm -fr inside the working directory skips the classifier because it 'would be allowed in acceptEdits mode'. A second, built-in dangerous-rm check fires on targets like $D/*, even in bypass mode, but loses track of a variable assignment when a heredoc comes earlier in the same command. The post ends with a PreToolUse hook that blocks every recursive spelling in all three modes and tells the model what to do instead.

claude-codehooks+5
pitfallsOct 21, 20268 min
315

Claude Code Auto Mode with DeepSeek, GLM or Kimi: What Each "temporarily unavailable" Reason Means and When Retrying Is Pointless (Fault-Injection Test)

On a third-party model, Claude Code auto mode sends one extra classifier request per side-effecting action, from your session model to the same backend: a 132,470-character system prompt, about 29.7K input tokens per check. When it fails, you see "xxx is temporarily unavailable, so auto mode cannot determine the safety of Bash". We injected faults and recorded each message: 500 (server error), 429 (rate-limited), 529 (overloaded) and connection resets (connection failed) are retried 5 times; no reply in 60 s gives (timed out). **No parentheses means a 4xx rejection: tried once, and retrying never helps.** A reply with no verdict gives "could not evaluate"; three in a row bring back manual approval. CLAUDE_CODE_AUTO_MODE_MODEL has no effect. Claude Code 2.1.285, real DeepSeek v4-pro and MiMo v2.5 backends.

claude-codedeepseek+7
pitfallsOct 11, 202612 min
460

Claude Code "Prompt is too long" vs "maximum context length": Why Auto-Compact Works for One and Not the Other (Tested)

Claude Code decides a request was too long by matching the error text. If the backend says prompt is too long or input is too long for requested model, it auto-compacts the conversation and retries — invisible to you. DeepSeek and OpenAI-style gateways say This model's maximum context length is …, which it doesn't recognize: you get API Error: 400 on every turn. Manual /compact works; the better fix is to declare the real window with CLAUDE_CODE_MAX_CONTEXT_TOKENS (non-claude- model IDs) or CLAUDE_CODE_AUTO_COMPACT_WINDOW (claude- IDs) so it compacts before hitting the limit. Tested on Claude Code 2.1.285 against a local stub.

claude-codedeepseek+6
pitfallsOct 5, 20267 min
474

Claude Code Stuck on a Spinner With No Response: It Waits 6 Minutes per Attempt, and 10 Retries Can Hang It for Over an Hour (Tested)

When Claude Code spins without output or sits at Retrying in 0s, the backend is usually not sending anything. Tested on 2.1.285 (API key + ANTHROPIC_BASE_URL): with no response headers it waits 6 minutes (360 s) per attempt before timing out — setting API_TIMEOUT_MS to 600000 or 900000 doesn't change that; only lower values work. With the default 10 retries it can hang for over an hour. A gateway that buffers the reply until generation finishes will never deliver a reply that takes over 6 minutes. Press Esc to interrupt. All tested against a local stub.

claude-codetroubleshooting+5
pitfallsOct 5, 20269 min
407

Published by Magic Tools

Claude Code Hooks: Custom Automation Workflows | Magic Tools