Claude Code for Clinicians Chapter 14

Chapter 14: Hooks

Everything we have covered in this part of the book — CLAUDE.md, slash commands, skills, subagents — depends on Claude choosing to follow your rules. The model reads the file, the model reads the description, the model is well-behaved most of the time. Most of the time is not all of the time. Hooks are what you reach for when “most of the time” is not good enough.

The clinical analogy is the EMR’s automatic-action rules. When a chemotherapy order for Cisplatin is signed, the EMR does not advise the physician to check the creatinine. It fires an alert automatically, every time, whether the physician wants it or not. The hard-stop is built into the workflow at the system level. The physician cannot accidentally bypass it by being in a hurry.

A hook is the Claude Code version of that hard-stop. It is a small shell command — a few lines of code on your computer — that Claude Code runs automatically at a specific moment in its lifecycle. The hook does not ask Claude for permission. It is not advice the model can ignore. It is plumbing. It always fires. It costs zero tokens.

The principle to keep in mind: CLAUDE.md is for suggestions. Hooks are for requirements.

🧠 Remember. Hooks always run. There is no “Claude decided not to” with a hook. If the hook is configured, it fires, every time, no exceptions. That is exactly why they are the right tool for non-negotiable rules.

The Lifecycle Events That Matter

There are several moments in a Claude Code session where a hook can attach. The four that matter most for daily work:

Where Hooks Live

Hooks are configured in .claude/settings.json under the "hooks" key. Here is a minimal example that runs Prettier (a code formatter) on every file Claude edits or writes:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write \"$(jq -r '.tool_input.file_path')\" 2>/dev/null || true"
          }
        ]
      }
    ]
  }
}

The three pieces are:

🔧 Technical Stuff. Every hook receives the same kind of JSON packet on standard input: the session ID, the event name, the tool name, and a tool_input object whose fields depend on the tool. Edit and Write give you .tool_input.file_path; Bash gives you .tool_input.command. The full schema is in the Claude Code hooks documentation; when in doubt, write a hook that just runs cat > /tmp/hook-input.json and inspect what arrives.

Example 1: Auto-Format After Every Edit

The Prettier hook above is the canonical example. It is the equivalent of an order-set template that always formats the order the right way — every time Claude edits a file, the file gets formatted before you ever look at it. You will never again ask Claude to “remember to run the formatter.”

The same idea works for Python:

{
  "type": "command",
  "command": "FILE=$(jq -r '.tool_input.file_path'); ruff format \"$FILE\" 2>/dev/null; ruff check --fix \"$FILE\" 2>/dev/null || true"
}

And for R:

{
  "type": "command",
  "command": "FILE=$(jq -r '.tool_input.file_path'); Rscript -e \"styler::style_file('$FILE')\" 2>/dev/null || true"
}

The || true at the end keeps a formatter hiccup from being reported as a hook error when the file type is not one it recognizes. The 2>/dev/null hides the noise.

💡 Tip. You can stack multiple PostToolUse hooks. The matcher is against the tool name, not the file name. If you want different behavior for .py vs. .R files, filter inside the command itself using shell case syntax.

Figure 21
Figure 21. a PostToolUse hook firing immediately after a file edit, with the auto-formatter output appearing right after the edit completes

Example 2: Block Destructive Commands (The Patient-Safety Hard-Stop)

This is the other side of the safety net, and it is the clearest hook analogy to a clinical hard-stop. A PreToolUse hook can inspect every shell command Claude is about to run and refuse the dangerous ones:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "CMD=$(jq -r '.tool_input.command'); if echo \"$CMD\" | grep -qE '(rm\\s+-rf\\s+/|DROP\\s+TABLE|TRUNCATE|DELETE\\s+FROM.*WHERE\\s+1\\s*=\\s*1)'; then echo 'BLOCKED: destructive command' >&2; exit 2; fi"
          }
        ]
      }
    ]
  }
}

The hook reads the command Claude is about to run from the JSON packet on its standard input. If the regex matches a known-destructive pattern (rm -rf /, DROP TABLE, TRUNCATE, a DELETE with no real WHERE clause), the hook exits with status 2 — the exit code Claude Code treats as “block” — and the command never runs. Whatever the hook printed to stderr is fed back to Claude, which sees the rejection and is forced to try a different approach. (Exit code 0 means allow; any other non-zero code is reported as a hook error rather than a block, so the 2 matters.)

This is the patient-safety equivalent of the EMR’s hard-stops. The doctor cannot order vincristine intrathecally because the EMR refuses to let the order through, no matter what. The same idea: even if Claude decides creatively that rm -rf is the right answer to a question, the hook stops it before any damage is done.

⚠️ Warning. A hook is only as good as its regex. The example above catches rm -rf / but does not catch rm -rf $HOME or rm -rf ~. Write your blocklist against your worst-case scenarios. And remember: a hook is a tripwire, not a fortress. A creative path around it (find / -delete, for instance) will not match the regex. Layer hooks with permission settings and the /sandbox command when the stakes are real.

Example 3: Log Every Edit

In a regulated environment, knowing exactly what Claude touched and when is part of the audit trail. A simple hook records it:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) $(jq -r '.tool_input.file_path')\" >> .claude/edit-log.txt"
          }
        ]
      }
    ]
  }
}

Every file Claude touches is recorded with a UTC timestamp. No prompt engineering, no token cost. Just a log file you can tail when you need to know what happened.

A KHCC Hook: Protect the Eval Cohort

This is the hook worth writing today, for any AI Office repository.

The eval results table — aidi_catalog.dbo.eval_runs — is the unified table where every evaluation run for every AI Office pipeline gets recorded. Each row is one run of one pipeline against the deceased-patient cohort, with the resulting accuracy, precision, recall, and per-case pass/fail flags. It is append-only by convention; nothing is ever supposed to update or delete a row. The whole eval discipline depends on this. If a non-eval session — say, a clinician exploring AKI cases who typos a filter in an UPDATE statement — accidentally writes to the table, the baseline is corrupted, and every future regression check is silently wrong. That is the single worst failure mode for a clinical AI eval suite.

A PreToolUse hook can make it impossible:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "CMD=$(jq -r '.tool_input.command'); if echo \"$CMD\" | grep -qE 'aidi_catalog\\.dbo\\.eval_runs'; then if [ \"$AIDI_EVAL_SESSION\" != \"1\" ]; then echo 'BLOCKED: eval_runs touched from non-eval session. Set AIDI_EVAL_SESSION=1 to override.' >&2; exit 2; fi; fi"
          }
        ]
      }
    ]
  }
}

What this does, step by step:

  1. Before any shell command runs, read the command from the hook’s standard input and inspect it for the string aidi_catalog.dbo.eval_runs.
  2. If the table name appears in the command, check whether the environment variable AIDI_EVAL_SESSION is set to 1.
  3. If it is not set, exit with code 2 — refusing the command and feeding Claude a clear error message that explains how to override.

Now an analyst exploring AKI cases cannot accidentally UPDATE the eval table because they typoed a WHERE clause. The automation script that is supposed to write to eval_runs sets the environment variable up front and proceeds normally. The hook is invisible when behavior is correct, and a hard-stop when it is not.

Pair this with the aidi-eval-runner subagent from Chapter 13: the subagent sets AIDI_EVAL_SESSION=1 before doing its work and is the only sanctioned path through which the eval table changes. Defense in depth — the subagent is the policy, the hook is the enforcement.

💡 Tip. Pair every hook with an explicit override mechanism (an environment variable, a flag file) rather than making it absolutely unconditional. Unconditional hooks frustrate users into disabling them, which is worse than no hook at all. A clear, deliberate override path keeps the hook on permanently while still permitting authorized work.

When to Reach for a Hook vs. a Rule

A simple framing:

⚠️ Warning. Do not put every rule in a hook. Hooks are powerful, but they are also noisy, and they run with your shell’s permissions. A project with twenty hooks is harder to debug than a project with three. Reserve them for the non-negotiables; everything else stays in CLAUDE.md.

Where Settings Live

Hooks compose across the three: a user-level hook fires in every project, a project-level hook fires only in that project, and a local file lets one developer add experimental hooks without inflicting them on the team. Type /hooks inside a session to see every configured hook and where each one came from.

Try This

  1. Open .claude/settings.json (create it if it does not exist). Add the auto-format hook for the language your project uses. Ask Claude to edit a file. Watch the formatting happen without you mentioning it.
  2. Add the destructive-command blocker. Then deliberately ask Claude to rm -rf something benign in a sandbox folder. Confirm the hook blocks it and read the rejection message.
  3. Add the audit log hook. Work for an hour. Then look at .claude/edit-log.txt and see exactly what got touched.

Watch Out