Tinker AI
Read reviews
intermediate 7 min read

Claude Code hooks: where they fire, what they can read, and what they can't

Published 2026-05-11 by Owner

Most Claude Code power users discover hooks the wrong way: they want to stop the agent from doing something specific, they add a hook, and it does nothing. Usually the problem is attaching a blocking hook to an event that can only observe, or writing a script that fails silently because the JSON input wasn’t what was expected.

Hooks are the deterministic layer underneath Claude Code. They’re not the model, they’re not MCP tools — they’re shell commands that run at fixed lifecycle points, with structured JSON input and structured JSON output. Understanding the contract for each event is the difference between hooks that work and hooks that silently do nothing.

The five events

PreToolUse fires after the model decides to call a tool but before the tool actually runs. This is the only hook that can block execution — if the hook returns a block signal, the tool call is cancelled and the model sees an error response instead. The full tool input is available here, so you can inspect exactly what the model is about to do.

PostToolUse fires after a tool has finished and its result is available. The hook receives both the original tool input and the tool’s output. It can add context to the conversation — useful for automatic annotations like “that file now has a lint error” or “that command took 45 seconds, consider caching” — but it cannot undo what the tool did. The tool already ran.

SessionStart fires once when a Claude Code session initializes, before the first user message is processed. It’s the right place to inject session-level context: current git branch, environment name, the current sprint, a reminder of which files are off-limits, or any preconditions the model should carry throughout the entire session. Context injected here persists for the session’s lifetime.

UserPromptSubmit fires after the user submits a message but before the model processes it. A hook here can inspect or augment the user’s input — useful for appending standard context to every message automatically, such as “the codebase uses Bun, not Node” or a reminder of a coding convention the user frequently forgets to mention. Unlike SessionStart, this runs on every turn, so injected context is always fresh but also always present in the request cost.

Stop fires when the model decides the task is complete and stops issuing tool calls. Useful for post-session cleanup, notifications, or logging final state. A common use: pipe the stop event to a notification script that sends a Slack message or plays a sound so you know the session finished while you were doing something else.

Shell commands, not plugins

Every hook is a shell command. The configuration in .claude/settings.json specifies a command string, and Claude Code runs it as a subprocess. This means the hook can be a Bash one-liner, a Python script, a compiled binary, or a Node.js script invoked with node or bun — whatever is on PATH.

Input arrives on stdin as a JSON object. Output is read from stdout. Exit codes matter: a non-zero exit code signals failure, which for PreToolUse can serve as the block mechanism. Hooks run synchronously in the Claude Code process flow — a hook that takes three seconds to respond adds three seconds of latency to that point in the session. Keep hooks fast, or at least fast for the events that fire on every tool call.

A minimal hook registration looks like this:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "command": "/usr/local/bin/my-hook-script"
      }
    ],
    "SessionStart": [
      {
        "command": "bun run /path/to/project/scripts/session-init.ts"
      }
    ]
  }
}

The matcher field on PreToolUse and PostToolUse filters which tool calls trigger the hook. "Bash" matches bash tool calls; "Write" matches file write calls; omitting matcher matches everything. Multiple hooks for the same event run in order — if the first hook blocks, subsequent hooks for that event do not run.

The input/output contract

Each event sends a different JSON shape to stdin. The exact schemas are documented in the Claude Code release notes, but the general pattern:

For PreToolUse, the input includes the tool name and the full input the model passed to it:

{
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf /tmp/build",
    "description": "Clean build directory"
  }
}

For PostToolUse, the input adds the tool’s output:

{
  "tool_name": "Bash",
  "tool_input": { "command": "git status" },
  "tool_response": {
    "stdout": "On branch main\nnothing to commit",
    "stderr": "",
    "exit_code": 0
  }
}

For UserPromptSubmit, the input contains the user’s message and the current conversation turn number. For SessionStart, it typically contains session metadata. For Stop, it includes a summary of the session’s outcome.

The output the hook writes to stdout is also structured JSON. A PreToolUse hook that wants to block returns something like:

{
  "action": "block",
  "message": "This command is not allowed in production environments."
}

A hook that wants to inject context — valid for PostToolUse, SessionStart, and UserPromptSubmit — returns:

{
  "action": "continue",
  "context": "Note: the tests are currently failing on CI; keep changes small."
}

Returning nothing, or returning {"action": "continue"} with no context, passes through without side effects.

What can block vs. what can only observe

This is the thing most people get wrong.

PreToolUse can block. Return {"action": "block"} and the tool call never executes. The model receives an error message and can decide how to proceed. This is how you implement guardrails: prevent specific bash commands from running, prevent writes to certain paths, require confirmation on destructive operations.

Every other hook can only observe or augment. PostToolUse cannot undo a file write. Stop cannot restart the session. UserPromptSubmit can add text to the conversation context but cannot prevent the message from being sent. SessionStart can inject initial context but cannot abort the session.

The practical consequence: if you need to prevent something, you need PreToolUse. If you’re trying to block a behavior that only becomes visible after a tool runs — for example, if you want to stop the agent from committing after seeing what it wrote — PostToolUse cannot do that. You would need to intercept the specific tool calls that precede the commit, before they execute.

A concrete example where this matters: say you want to prevent the agent from modifying any file under src/db/migrations/. The wrong approach is a PostToolUse hook that checks file paths and raises an alarm. By then the write already happened. The right approach is a PreToolUse hook on the Write tool that inspects the file_path parameter before the write executes:

#!/usr/bin/env python3
import json, sys

data = json.load(sys.stdin)
file_path = data.get("tool_input", {}).get("file_path", "")

if "src/db/migrations/" in file_path:
    print(json.dumps({
        "action": "block",
        "message": "Migration files must be created manually — the agent cannot modify them directly."
    }))
    sys.exit(0)

print(json.dumps({"action": "continue"}))

And a SessionStart hook that injects useful context on every session:

#!/usr/bin/env bash
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
dirty=$(git status --short 2>/dev/null | wc -l | tr -d ' ')

cat <<EOF
{
  "action": "continue",
  "context": "Current branch: ${branch}. Uncommitted files: ${dirty}. Production deploys go through CI — do not run deploy scripts directly."
}
EOF

This runs before the first user message, so the model knows the branch and the deploy restriction for the whole session without the user having to state it.

A real guard that prevents force pushes:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "command": "python3 /path/to/hooks/guard-git.py"
      }
    ]
  }
}
#!/usr/bin/env python3
import json, sys

data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")

BLOCKED = ["git push --force", "git push -f", "git reset --hard"]
for pattern in BLOCKED:
    if pattern in command:
        print(json.dumps({
            "action": "block",
            "message": f"Blocked: '{pattern}' is not allowed. Use --force-with-lease or ask for confirmation."
        }))
        sys.exit(0)

print(json.dumps({"action": "continue"}))

The guard reads stdin, checks the bash command for patterns, and either blocks with a message or passes through. The model sees the block message as a tool error and can explain the situation or ask the user to override.

Debugging hooks that fail silently

The most common failure mode: the hook is registered, Claude Code runs it, but nothing happens. Usually one of three causes:

  1. The script is not executable — chmod +x the script file
  2. The JSON parsing fails — the script crashes on malformed input and exits non-zero, which Claude Code may interpret differently depending on the event type
  3. The output is not valid JSON — hooks that return plain text or nothing may be silently ignored

The fix for all three: log everything to a file on first iteration. Add this to the top of any hook script:

#!/usr/bin/env bash
# Log raw input for debugging
tee /tmp/hook-debug.log | your_actual_hook_logic

Or in Python:

import json, sys

raw = sys.stdin.read()
with open("/tmp/hook-debug.log", "a") as f:
    f.write(raw + "\n")

data = json.loads(raw)
# ... rest of logic

Check /tmp/hook-debug.log after triggering the hook. If the file is empty, the hook script was never called — check the command path and executable permission. If the file has content, the hook is running; look at whether the output is valid JSON.

This one step eliminates most hook debugging sessions. Many hooks fail because the author assumed a JSON shape that turned out to be slightly different in practice. Seeing the actual input ends the guesswork.

The relationship to skills and MCP

Hooks are often confused with two other Claude Code extension points.

Skills (slash commands) are model-invoked. The model decides to run /review because it matches the task. Hooks are runtime-invoked — Claude Code calls them regardless of what the model wants. A hook on PreToolUse fires every time a matching tool is called, no decision required.

MCP (Model Context Protocol) adds new tools to the model’s tool palette. The model can call them by name. Hooks operate on existing tool calls — they intercept tool usage that was already going to happen.

The three are complementary. A typical production setup uses all three: MCP to give the model access to internal systems, skills to define repeatable workflows, and hooks to enforce guardrails and inject session context automatically. None of them replaces the others.

As the hooks system matures, the event taxonomy will likely expand — there are natural gap points (before session end, after compaction, on tool error) that aren’t covered yet. The five current events cover the critical intervention points: before and after tool use, session boundaries, and user input.

The two worth building first are PreToolUse for blocking and SessionStart for context injection. Start with a SessionStart hook that injects the git branch and any standing constraints. Add a PreToolUse hook on Bash that blocks the one or two patterns that have burned you before. Both are small scripts, both are immediately useful, and both compound over time as the session’s behavior becomes more predictable. That predictability is what makes an agent trustworthy enough to run without watching every step.