Articles/AI & Tooling/Claude Code Hooks, by Example

Claude Code Hooks, by Example

CLAUDE.md asks Claude Code nicely. Hooks make things happen for certain. A short, practical tour of how hooks are wired, built around two that I actually run: one that installs dependencies on session start, and one that filters noisy command output before it reaches the model.

Some instructions are too important to leave to a model's good intentions. "Install the dependencies before you run anything." "Never commit to main." "Run the formatter after every edit." You can put these in CLAUDE.md and Claude Code will usually follow them, but usually is not always, and for the things that genuinely have to happen, usually is not good enough.

That is what hooks are for. A hook is a shell command the Claude Code harness runs itself when an event fires, deterministically, whether the model thinks about it or not. Here is a short, practical tour around two I actually run: one that gets a fresh cloud session ready to work, and one that keeps noisy output out of the context window.

Advisory versus guaranteed

Think of it as a spectrum from advisory to guaranteed.

CLAUDE.md is advisory. The model reads it and almost always respects it, which is perfect for preferences and conventions: indentation, naming, architecture notes. But it runs through the model's judgment, so it can get compacted away on a long session or simply not acted on.

Hooks are guaranteed. When the event fires, the command runs, full stop, no "the model forgot." That makes them the right tool whenever correctness depends on something happening every time: setup before work starts, a check before a commit, a transformation on every file Claude touches. If skipping it once would be a bug, it belongs in a hook.

The rule of thumb: if it is a preference, write it down for the model; if it is a guarantee, wire it into a hook.

How a hook is wired

Hooks live in .claude/settings.json under a hooks key, grouped by the event that triggers them. The ones you will reach for most: SessionStart, UserPromptSubmit, PreToolUse and PostToolUse (around any tool call), and Stop. Each entry can carry a matcher to narrow which tools it applies to, plus the hooks to run:

JSON
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh" }
        ]
      }
    ]
  }
}

Two mechanics make hooks more than shell aliases. Every hook receives a JSON payload on stdin describing the event, the session id, working directory, and for tool events the tool's input, so your script can decide what to do. And a hook can talk back by printing JSON to stdout to allow, block, or rewrite an action. The second example leans on exactly that.

Two variables matter for portable hooks: $CLAUDE_PROJECT_DIR is the repo root, and $CLAUDE_CODE_REMOTE is set when the session runs in Claude Code on the web rather than on your machine.

Example 1: install dependencies on session start

Claude Code on the web spins up a fresh container per session. On any project with a build step it starts empty, no node_modules, so the first command Claude runs fails. This bit me, and a SessionStart hook is the fix.

Put the script in .claude/hooks/session-start.sh:

BASH
#!/bin/bash
set -euo pipefail

# Only run in Claude Code on the web; no-op on a local machine that is
# already set up.
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
  exit 0
fi

cd "$CLAUDE_PROJECT_DIR"

# Prefer the pinned Node version, but do not fail the session if nvm is fussy.
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
  . "$NVM_DIR/nvm.sh"
  nvm install 24 >/dev/null 2>&1 || true
  nvm use 24 >/dev/null 2>&1 || true
fi

# Idempotent, and it benefits from container caching.
npm install

Then register it:

JSON
{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" }
        ]
      }
    ]
  }
}

A few deliberate choices. The CLAUDE_CODE_REMOTE gate lets local sessions, already set up, skip the work. The npm install is idempotent, so resuming costs nothing and benefits from container caching. The Node setup is best-effort: if nvm misbehaves it does not abort the session, since the build tolerates a slightly older Node. It runs synchronously, so the session waits until dependencies are ready, which is what you want when the next action might be a build. To start instantly and install in the background instead, a hook can opt into async mode by printing {"async": true, "asyncTimeout": 300000} first.

Once this is committed to your default branch, every future web session arrives ready to work instead of stumbling on the first command.

Example 2: filter noisy output before Claude reads it

The most common way to burn through a context window is to let a giant blob of output land in it. Run the test suite and Claude reads thousands of lines to find the three that failed, and they sit in the window for the rest of the session. A PreToolUse hook can trim the firehose before Claude ever sees it.

The trick: a PreToolUse hook can rewrite the tool's input. This script checks whether the Bash command is a test runner and, if so, pipes it through a filter that keeps only failures:

BASH
#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')

# If this is a test run, rewrite it to surface only failures.
if [[ "$cmd" =~ ^(npm\ test|pytest|go\ test) ]]; then
  filtered="$cmd 2>&1 | grep -A 5 -E '(FAIL|ERROR|error:)' | head -100"
  echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{\"command\":\"$filtered\"}}}"
else
  echo "{}"
fi

Wire it to Bash calls with a matcher:

JSON
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/filter-test-output.sh" }
        ]
      }
    ]
  }
}

When the command is not a test run, the script prints {} and steps aside. When it is, updatedInput hands Claude the modified command, so a run that might have dumped tens of thousands of tokens of passing output returns a few hundred lines of failures. It is the same context discipline from getting more out of Claude Code, except enforced rather than hoped for.

Gotchas to watch for

A few things that will trip you up the first time:

  • Make the script executable. chmod +x .claude/hooks/your-hook.sh, or the hook silently does nothing.
  • Use $CLAUDE_PROJECT_DIR for paths. A hook does not necessarily run from your repo root, so reference scripts and files through the variable rather than a relative path.
  • Hooks run on the host, with your privileges. They are real shell commands on your machine, so keep them fast, keep them safe, and do not put anything in a hook you would not run yourself.
  • Gate web-only work. Setup that only makes sense in a fresh cloud container should check $CLAUDE_CODE_REMOTE so it does not run locally.
  • Async trades safety for speed. Synchronous hooks guarantee the work finishes before the session proceeds; async starts the session faster but introduces a race if Claude depends on something the hook is still doing.

Hooks can also be bundled into a plugin to share them across a team without everyone hand-editing settings. But you do not need that to start. Pick the one thing in your workflow that must happen every time, and wire it into a hook. That is the idea in one line: stop hoping, start guaranteeing.