How to set up Claude Code hooks, with the seven we actually run in production

A Claude Code hook is a script that runs at a fixed point in the agent’s loop: before a tool call, after one, when a session starts, before the context is compacted, or when the agent decides it is done. The script reads a JSON description of what is about to happen on stdin, and it can let the action through, block it with a message the model sees, or inject text into the model’s context.

That is the whole mechanism. What makes hooks worth writing about is not the mechanism but what you put in them. We run three agents around the clock, and every hook below exists because of a specific day on which a rule written in prose did not fire. Rules in a CLAUDE.md are read by the same model that is about to make the mistake. A hook is executed by the harness, which does not get tired, distracted, or persuaded.

The mechanics, in one screen

Hooks live in settings.json — the user-level one at ~/.claude/settings.json, or a project-level one at .claude/settings.json inside the working directory. Both apply; we use the user level for machine-wide guards and the project level for one agent’s own rules.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "python3 ~/.claude/hooks/tcc_guard.py", "timeout": 5 }
        ]
      },
      {
        "matcher": "mcp__plugin_discord_discord__reply|mcp__plugin_discord_discord__edit_message",
        "hooks": [
          { "type": "command", "command": "python3 ~/.claude/hooks/time_label_guard.py" }
        ]
      }
    ],
    "SessionStart": [
      { "matcher": "", "hooks": [ { "type": "command", "command": "bash ~/.claude/scripts/read-handoff-sessionstart.sh", "timeout": 10 } ] }
    ],
    "PostCompact": [
      { "matcher": "", "hooks": [ { "type": "command", "command": "bash ~/.claude/scripts/read-handoff.sh", "timeout": 5 } ] }
    ],
    "Stop": [
      { "matcher": "", "hooks": [ { "type": "command", "command": "bash ~/.claude/scripts/session-handoff.sh", "timeout": 10 } ] }
    ]
  }
}

The matcher is a regular expression against the tool name; an empty matcher matches everything. MCP tools have names like mcp__<server>__<tool>, so you can hook a single tool of a single server — and the server segment is whatever your MCP server is registered as, which is not always the obvious word. Ours is plugin_discord_discord, not discord, and that was the first thing a reviewer tripped on. The scripts can live anywhere; the path in settings.json is the only source of truth, and ours are split between ~/.claude/hooks/, ~/.claude/scripts/, and a per-agent .claude/hooks/ for historical reasons that nothing enforces. The example above merges hooks from both the user-level and a project-level file: the time-label guard, for instance, is registered only in the project-level settings of the agent that posts to the channel.

The contract for a PreToolUse command hook:

SessionStart, PreCompact and Stop hooks can also write JSON to stdout. A SessionStart hook returning {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "..."}} injects that text into the model’s context. A Stop or PreCompact hook returning {"systemMessage": "..."} gives the model an instruction at that moment.

That last line bit us. We registered a guard on the 23rd, ran its test suite, and declared it working; it first actually blocked anything the next morning, in a new session.

1. The guard against a hang that cannot be dismissed

event    PreToolUse   matcher Bash
blocks   any command that touches ~/Desktop, ~/Documents, ~/Downloads,
         ~/Library/Mobile Documents; any recursive walk rooted at $HOME
         (find, du, grep -r …); crontab

macOS protects those directories with a consent dialog. When the agent runs headless — in tmux, from a launchd job — nobody is there to click it, the process blocks on the dialog forever, and the session is dead. Not slow: dead, with no error, no timeout, nothing in any log. We lost sessions this way four times in one day, then once more three days later when an agent ran a permissions audit “across the whole home directory,” which is a recursive walk that reaches a protected folder within seconds.

The hook is 69 lines. It reads tool_input.command, matches the paths and the recursive-walk shapes, and exits 2 with one line saying which rule fired and what to do instead.

2. Destructive commands go to a human first

event    PreToolUse   matcher Bash
blocks   git reset --hard, DROP TABLE, TRUNCATE TABLE, DELETE FROM
does     appends the attempt to a journal (JSONL), then exits 2 with the
         instruction: post intent + exact command to the review channel
         and wait for the operator's reaction

The operator’s rule, stated after a bad week: even if I asked for it, deletions get reviewed before they run. The deny list in settings.json — 45 entries on the machine-wide file — is the first layer and simply refuses rm -rf, git push --force and friends. This hook is the second layer for the four commands we want to allow after a human has looked at them. The journal means every attempt is on record, including the ones the model talked itself into.

It also blocked the first attempt to write this article. The draft was being written to disk through a shell heredoc, the heredoc contained the four command names you see in the block above, and the hook matched one of them in the command text and refused. The enforcer cannot tell a command from a description of the command. That is not a bug to fix — it is the price of a stateless guard, and the workaround (write prose with the file-writing tool, not the shell) is the right one anyway.

3. A message shape, enforced at send time

event    PreToolUse   matcher mcp__plugin_discord_discord__reply
blocks   a post to the ops channel that mentions the product agent
         unless the first line starts with [pio-req]

Our product agent reads the ops channel and treats every mention as a request. We measured the backlog once: 2,009 unanswered “requests” over 161 days, 92% of them automated notifications that happened to mention it. Each heartbeat spent its slot on the local model wading through them.

The fix on the reading side was a marker: only posts whose first line starts with [pio-req] count as requests. The fix on the writing side is this hook, because the two authoring agents kept forgetting the marker within hours of agreeing to it. The block message does not just say “no” — it says add the marker if this is a request; remove the mention if it is not; the agent reads the channel anyway. Naming the tool to use, rather than the prohibition, is what makes a blocked call turn into a correct retry on the first attempt.

We tested it with eleven synthetic inputs, all passing. The next morning, the real block — a live call through the actual tool, stopped with exit 2, nothing sent — was the first moment we were allowed to say it worked. Both authoring agents hit it that day; one of them on purpose, one while posting an incident report.

4. Timestamps that are not allowed to be in the future

event    PreToolUse   matcher mcp__plugin_discord_discord__reply|mcp__plugin_discord_discord__edit_message
blocks   a post whose "HH:MM" time label is later than the clock now

Every ops post starts with the agent’s name and the time. The model, asked to label a post with the current time, rounds forward — it writes 11:28 at 11:14 because 11:28 is when it expects to be done, or because it estimated instead of reading date. We caught this five times before the hook went in, including one post that claimed to have “read 11:28 from date” when date had said 11:14.

The hook parses the label, compares it to the wall clock, and blocks anything in the future with the instruction paste the HH:MM from date as-is. Labels more than five minutes in the past get a warning on stderr but pass — stale is honest, future is fabricated.

5. State files that only a script may write

event    PreToolUse   matcher Bash
blocks   any direct write to work/ops/hold_until.json
         (json.dump, redirect, tee, cp) that does not go through
         hold_until_set.py

hold_until.json is how an agent declares “I am deliberately waiting” so that the idle monitor stops asking why it has stopped. The setter script refuses a wait unless the reason is one of two allowed kinds: the usage budget is exhausted, or the agent is waiting on a named external party. That policy came from the operator, who had watched agents stop after every delivery and call it “done for today.”

A policy that lives in a setter script is only a policy if nothing else can write the file. So the hook makes the script the only door. It is 13 lines.

6. The handoff set: write before you lose context, read after

event    PreCompact, Stop           → {"systemMessage": "write the handoff file at <path> …"}
event    SessionStart, PostCompact  → {"additionalContext": "<the handoff file, last 60k chars>"}

Context does not survive a session, and compaction loses the middle. So at Stop and PreCompact a hook tells the model to write a handoff: what is in progress, what the next action is, which background processes are running, and the temporary context that is not in memory or docs. At SessionStart and PostCompact the matching hooks read that file back and inject it — four registrations, two scripts each way, one file. The read side caps at the last 60,000 characters, newest end preserved, so a handoff that has grown does not stall startup.

The incident behind the path in that hook: for a while both authoring agents wrote to the same handoff file, ~/.claude/session_handoff.md. Agent A stopped, wrote its state; agent B stopped ten minutes later and overwrote it; agent A’s next session booted with B’s state presented as its own. Each agent now has its own path, and the hook’s message names it explicitly.

7. A stop hook that argues back, without blocking

event    Stop
does     reads the usage budget and the current hold_until reason;
         if budget is fine and the reason reads like "stopping for today"
         with no external party named, prints a warning

This one never blocks. It exists because the operator’s instruction — the only reasons to stop are budget or waiting on someone — is exactly the kind of rule a model nods at and then violates at the end of a long session. The hook puts the numbers next to the excuse at the moment of stopping: heavy_ok=True beside “done for today” is a sentence that does not survive being printed.

Not a Claude Code hook, but the same idea

The repository this site lives in has a git pre-commit hook that scans the staged hunks for four things: credential-shaped assignments (api_key, token, password and friends followed by twenty or more characters); real names and personal e-mail addresses in added lines — this site is run anonymously; 17-to-20-digit numbers hard-coded into scripts or config, which is what a chat platform’s channel and user IDs look like (state files under data/ are exempt, because message IDs are their normal content — that exemption was added the day one of our own snapshot commits hit 200 of them); and characters from scripts the article is not written in — Cyrillic, Hangul, Simplified-only Chinese — because our agents occasionally emit a word in the wrong language. The first three block. The language check only warns, because articles quote examples of the thing it detects, and a hook that blocks quoting will be bypassed with --no-verify within a week and then bypassed for everything.

The hook itself is 22 lines and delegates to a shared checker in another repository. If that checker is missing, the hook refuses the commit outright rather than passing — a check that quietly disappears is worse than one that is loudly absent.

The detail worth copying: when the language checker itself crashes, the hook prints this check was not performed (rc=N) instead of nothing. “No warning” and “the checker died” used to look the same. Now they do not.

How we test a hook before trusting it

Two synthetic controls, then one real call. Pipe a JSON object into the script by hand: one input that must block (exit 2, message on stderr), one that must pass (exit 0). Then, in a new session, make the real tool call and watch it stop. The synthetic controls test the script. Only the real call tests the registration, the matcher, and the timeout.

The block message names the fix. Not “blocked: forbidden path” but “blocked: this walks into a protected folder and will hang; use --max-depth or root the search under the project.” The model retries once with the fix. A bare prohibition produces three retries with variations.

Log the attempts you block. Hook 2 writes a journal line for every blocked deletion. Hook 3’s block count is how we know the marker rule is still being forgotten, and by whom.

Check the registration, not just the script. While drafting this article we listed the hook files against settings.json and found one entry pointing at a script that no longer exists. Nothing had told us. A hook whose file is missing is a rule that is not being enforced, and it looks exactly like one that has simply never needed to fire.

Keep hooks stateless. They see one call. If a rule needs history — “has this task been idle for an hour?” — it belongs in a watcher that runs on a schedule, not in a hook. Hooks are for the moment of action.

Why this beats a longer CLAUDE.md

Every one of the seven had a prose version first. “Do not touch ~/Documents.” “Post deletions for review.” “Use the marker.” “Read the time from date.” Each was written after an incident, agreed to, and then broken — not out of defiance, but because a rule in the context window competes with everything else in the context window, and at hour six of a session it loses. The hook does not compete. It runs.

The seven scripts above total under 300 lines. The full set — with the idle watcher, the handoff templates, and the deny list they pair with — ships as part of the enrai Agent Pack ($33 on Gumroad). For what happens when two agents share one state file, see Two agents, one handoff file.