Ramiz RajaBook a call
← Writing
Working With AI Agents

Claude Code Hooks: Three I Run Every Day

Which event does which job, what the JSON in and the exit code out actually mean, and the three guards I attach to PreToolUse, Stop and SessionEnd, with what each one caught.

Ramiz RajaOctober 26, 20268 min read
Claude Code Hooks: Three I Run Every Day

One Claude Code session launched about twelve background commands, each burning 8 to 10 percent CPU. Fine on its own. Three sessions doing the same at once put about 36 busy processes on a 12-core Mac, on top of the builds and tests it was already running, and everything slowed to a crawl. No session did anything wrong, and none of them could see the others.

A rule in CLAUDE.md could not fix that, because a rule lives inside one session and the problem lives across all of them. A hook runs outside the session, so it can count what every session is doing. That is the case for claude code hooks in one line. A CLAUDE.md rule is a request, a hook is a guard. A rule says what I want; a hook fails the call when I don't do it, as long as the hook itself runs correctly.

One event per job

Claude Code fires a hook at a named point in the session; picking the wrong point is the most common way one ends up decorative.

  • PreToolUse fires before a tool call and can block it before it runs (PermissionRequest can also deny, when a permission prompt would appear or the session cannot prompt).
  • Stop fires when the agent finishes responding. The last moment to refuse to end a turn.
  • SessionEnd fires when the session terminates. It has no decision control: it can clean up, not block.

Input arrives as JSON on stdin: session_id, transcript_path, cwd and hook_event_name, plus tool_name and tool_input on tool events. Stop also gets stop_hook_active, true when Claude Code is already continuing because of a stop hook, which the docs say to check so you don't block on a condition that never resolves. Reference: code.claude.com/docs/en/hooks, checked 2026-10-23.

The output contract has three cases; one matters more than people expect:

ExitWhat happens
0Success. JSON you printed is applied; the action proceeds unless that JSON blocks it.
2Blocking error, on events that can block. Claude sees your stderr.
any otherNon-blocking error. The action goes ahead, with a hook error notice in the transcript.

That last row is the one to internalise: a hook that crashes fails open. The table is the plain case, for plain-text or empty stdout. A hook that prints valid decision JSON can also block or deny through its fields, whatever the exit code, and the reference has an onFailure: "block" option for handlers you want to fail closed; check it before relying on fail-open.

Registration nests event, matcher group and handler in a settings file. A project file covers one repo; a user-level file covers every session on the machine.

"hooks": {
  "PreToolUse": [
    { "matcher": "Bash",
      "hooks": [ { "type": "command", "command": "./hooks/budget-guard.sh" } ] }
  ],
  "Stop": [
    { "hooks": [ { "type": "command", "command": "./hooks/claim-check.sh" } ] }
  ],
  "SessionEnd": [
    { "hooks": [ { "type": "command", "command": "./hooks/cleanup.sh reap", "timeout": 10 } ] }
  ]
}

Two details from the reference: shell form goes through sh -c, so the script needs its own shebang and keeps its bashisms inside; and a per-handler timeout is optional, except that SessionEnd defaults to 1.5 seconds, which matters below.

Your first Claude Code hook in two minutes

Hooks go in a settings file: ~/.claude/settings.json for every project, .claude/settings.json for one repo you can commit, .claude/settings.local.json for one repo and not committed (reference checked 2026-10-23). The matcher filters when it fires; for PreToolUse it is compared to the tool name, so Bash matches only the Bash tool and Edit|Write matches either. The reference lists 33 events. Exit 2 blocks on PreToolUse, UserPromptSubmit, Stop and several more, but only shows stderr on PostToolUse, SessionStart and SessionEnd.

The smallest useful guard reads the call from stdin and refuses rm -rf:

#!/bin/sh
# ./hooks/no-rm-rf.sh  (needs jq; chmod +x it first)
cmd=$(jq -r '.tool_input.command // ""')
case "$cmd" in
  *"rm -rf"*) echo "rm -rf is blocked in this repo" >&2; exit 2 ;;
esac
exit 0

Register it under PreToolUse with matcher Bash, then test it before trusting it, by piping the payload it will receive:

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/build"}}' | ./hooks/no-rm-rf.sh
echo "exit=$?"   # 2, with the message on stderr

Run it again with npm test as the command and you get exit 0 and no output.

That exit-2 version is the simplest. The reference's current way to block a PreToolUse call is JSON on stdout with permissionDecision: "deny", exit 0. Here is the same guard in that form, with the settings file that wires it up. Save the script as hooks/no-rm-rf.sh, chmod +x it, and put the settings in .claude/settings.json:

#!/bin/sh
cmd=$(jq -r '.tool_input.command // ""')
case "$cmd" in
  *"rm -rf"*)
    jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "rm -rf is blocked in this repo"}}'
    ;;
esac
exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "./hooks/no-rm-rf.sh" }
        ]
      }
    ]
  }
}

I ran this in a scratch repo on 2026-10-26: I asked Claude Code to run rm -rf victim and the call was refused with the reason text, and the directory was still there. If jq is missing the script errors and the call goes ahead, which is the fail-open row in the table above.

The three hooks below are the same shape with more going on inside.

Hook 1: a CPU budget guard on Bash

The failure is the one from the opening, so here is only the guard.

The hook runs on PreToolUse matched to Bash. If the call is not a background launch it exits immediately. The fast path matters: it fronts every shell command, so the common case is two string checks and no jq fork.

For a background launch it takes one ps snapshot and counts CPU-active descendants of every claude process on the machine. The cap is half the core count, 6 on a 12-core Mac; a process counts as a worker at 3 percent CPU or more. Foreground calls are never blocked, because that would wedge a session mid-build.

At the cap the agent gets the count, the cap, which session owns the busy workers, and three ways out: run it in the foreground, batch N items into one loop, or wait. Then the exception:

CLAUDE_MAX_BG_WORKERS=8 ./run-release-gate.sh

It parses the prefix from the command text, because a variable set inside the command never reaches the hook's environment. The script ships with four tests (prefix raises the cap, invalid prefix blocks, no prefix blocks, environment variable works), all passing.

One wrinkle: the block JSON uses the older top-level {"decision":"block","reason":...}. The reference calls that deprecated for PreToolUse and maps the old block to permissionDecision: "deny". It still works, so I haven't rewritten it; a new hook should print hookSpecificOutput.permissionDecision. The deeper limit: it measures CPU at that instant, so workers alive but sleeping pass, then wake together. The machine-level story is in running several Claude Code agents on one machine.

Hook 2: a Stop hook that refuses an unverified claim

This one is about a sentence. A model can produce "the build is still running" from the text in front of it instead of the process table. A rule asking for a fresh check did not hold, so it became a Stop hook.

It asks two questions of the turn, scanning the assistant text across the whole turn, not only the last message. Does the final text contain a status claim? The pattern started as literal phrases and broke under review: seven rounds kept finding gaps, from "is running" to "continues to run" and "remains running", so it ended as a subject-plus-state pattern instead of a phrase list. Does the turn also contain a qualifying check? A Bash call whose leading token is ps, adb, stat, date, logcat, screencap or ls -la, or a Read of an image.

A claim without a check and the hook exits 2 with an explanation on stderr, so the turn does not end. Exit 2 on Stop sends stderr to Claude as the reason to continue; the reference confirms the routing.

The second guard is the part people don't expect. One verified look is a snapshot, not monitoring. Even with a real ps, it blocks unless the turn schedules a wake-up of 900 seconds or less whose prompt has a verification verb and a concrete target, and whose call didn't error. "Check back later" fails the target test.

Cost matters. After a rewrite an ordinary turn went from 458 ms to 123 ms and a 200-call turn from 3,543 ms to 94 ms. A check that takes minutes gets switched off, and a switched-off hook is a rule again.

I won't retell the review rounds. That story, including a 768-test report that a reviewer failed with two High findings, is in an agent that says the tests passed.

Hook 3: cleanup at Stop and SessionEnd

This one uses both events in two modes, because warning and cleaning are different jobs.

On Stop it audits: above four CPU-active processes owned by the session, it prints pid, CPU and age and says to kill what is no longer needed or make sure a wake-up is monitoring it. It never kills here, since a turn can end with work in flight. On SessionEnd it reaps: nobody is left to read the output, so CPU-active descendants above the threshold are treated as abandoned (infrastructure processes are excluded, and sleeping ones can remain). It logs the list, sends TERM, waits two seconds, then KILLs what ignored TERM. Both modes act only on descendants of the session's own process; a worker that double-forked is unreachable, hence the plain & guidance.

The log earns its place: 363 session-end entries listing 891 processes, at most 12 in one session, and 25 rows at 50 percent CPU or higher. The highest is a Java process at 238.6 percent, seven seconds old.

The catch is about the contract, not the intention. SessionEnd hooks share a 1.5-second budget by default, raised if the handler sets a timeout, up to 60 seconds; checked 2026-10-23. My reap handler sets none, and the script sleeps two seconds between TERM and KILL, so on the default budget the KILL half is past the deadline, unless CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS raises it; I have not checked that it is unset in my environment, only that my settings do not set it. The log line is written before either signal and cannot tell me whether the second half ran. I read 891 as "processes identified", not "processes that died", which I should have caught at the time. The fix is a timeout on that handler or a grace outside the hook's critical path; I have not shipped it yet, so treat it as an open defect.

When a hook errors

A hook error notice saying Failed with non-blocking status code means the exit was neither 0 nor 2 and the action went ahead, so what you thought was guarded wasn't. claude --debug gives the full stderr and the parse message, because Claude never sees stderr from a hook that exits 0. /hooks shows what is registered. Before any of that, pipe a sample payload in.

What I did not verify

  • Whether the KILL ever fired. The log records intent before the signals.
  • That the budget guard improves wall-clock build time. I never ran an A/B with it off.
  • Where the status hook stops guarding: it exempts work spawned asynchronously in the same turn, and it does nothing when stop_hook_active is true, so a second pass is not checked.
  • The false-positive rate of the claim pattern. I know which wordings it was extended for, not how often it fires on a clean turn.
  • The status hook against the newer Stop input. The reference now says to use last_assistant_message rather than the transcript, which is written asynchronously. Mine parses the transcript.
  • Two of the three hooks have no test file; only the budget guard does. The others rest on changelog headers and daily use.

The durable part is that distinction. A rule can be read, agreed with, and skipped under load, as happened at the top. A guard has to hang on an event, return the right code, and be tested against the payload it will really receive.

For when to hand work to a delegate instead of doing it inline, see Claude Code subagents.