Claude Code Hooks: Turning Requests Into Rules

Stop asking Claude Code to format your code, run your tests, or avoid your migrations. Hooks let the harness enforce it — deterministically, every time.

Claude Code is genuinely useful out of the box, but the moment you want it to do something every single time — format your code after an edit, block a dangerous command, remind itself of your project's conventions — you hit a wall. You can ask it to, but "ask nicely and hope" is not automation. Hooks are how you make a behavior guaranteed instead of hoped-for.

A hook is a command that Claude Code runs automatically at a specific moment in its lifecycle. It is deterministic: the harness executes it, not the model, so it happens whether or not Claude "remembers" to. This is the difference between a preference and a rule.

Where hooks live

Hooks are configured in your settings JSON, at one of three scopes:

  • ~/.claude/settings.json — applies to all your projects
  • .claude/settings.json — one project, shared with your team via git
  • .claude/settings.local.json — one project, not committed

The structure groups hooks by the event that triggers them:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
        ]
      }
    ]
  }
}

That single block means: after any Edit or Write tool call, run Prettier on the file that was just changed. No more asking Claude to format — it is formatted, every time, by the harness.

The events worth knowing

There are a couple dozen hook events, but a handful cover the vast majority of real use:

  • PreToolUse — fires before a tool runs, and it can block the call. This is your safety gate.
  • PostToolUse — fires after a tool succeeds. This is your side-effect slot: formatting, linting, logging.
  • UserPromptSubmit — fires when you submit a prompt, before Claude sees it. Its stdout gets added to Claude's context, so you can inject information into every turn.
  • SessionStart — fires when a session begins or resumes. Also great for injecting context; it supports a matcher of startup, resume, clear, or compact.
  • Stop — fires when Claude finishes responding.

The matcher field filters which tool (or which sub-case) the hook applies to. For tool events it is a regex on the tool name — Edit|Write, or mcp__github__.* to match a whole MCP server's tools. Matchers are case-sensitive: Edit is not edit.

How a hook talks to Claude

Every hook receives a JSON payload on stdin describing what is happening:

{
  "session_id": "abc123",
  "cwd": "/path/to/project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "npm test" }
}

That is why so many hook examples pipe through jq — it is the quickest way to pull a field like .tool_input.command out of the payload.

The hook talks back through its exit code:

  • Exit 0 — all good, proceed. For UserPromptSubmit and SessionStart, anything the hook prints to stdout is injected into Claude's context.
  • Exit 2 — block the action. Whatever you write to stderr becomes the reason Claude sees.
  • Any other code — the action proceeds, but the error is logged.

So a PreToolUse hook that exits 2 and prints "don't touch production config" to stderr will genuinely stop the tool call and tell Claude why. That is real enforcement, not a suggestion.

Three hooks worth stealing

1. Format on every edit. The PostToolUse + Prettier example above. Set it once, never think about formatting again.

2. Block dangerous commands. A PreToolUse hook on Bash that inspects .tool_input.command, and exits 2 if it matches something you never want run unattended — rm -rf, a DROP TABLE, a write to .env:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [ { "type": "command", "command": "jq -r '.tool_input.command' | grep -qE 'rm -rf|DROP TABLE' && echo 'blocked: destructive command' >&2 && exit 2 || exit 0" } ] }
    ]
  }
}

3. Re-inject context after compaction. When a long session compacts, Claude can lose the thread. A SessionStart hook with the compact matcher can print your project's key conventions — or the last few git commits — straight back into context, so the compacted session picks up oriented instead of amnesiac.

The gotchas that will bite you

A few things that cost people an afternoon:

  • PostToolUse fires after execution — you cannot undo the tool call from it. It is for side effects only. If you want to prevent something, that is PreToolUse.
  • Stop hooks fire on every response, not just at the end of a task. If you build a "keep working until X" hook, remember it runs constantly and gate it accordingly.
  • An unconditional echo in your shell profile will break JSON-producing hooks. Hooks run in a non-interactive shell that still sources your profile; a stray echo pollutes stdout. Wrap profile output in if [[ $- == *i* ]]; then ... fi.
  • jq is a hidden dependency. Most examples assume it. brew install jq (or apt-get install jq) first.
  • Command hooks must be executable if you point at a script — chmod +x.

Why this matters

The mental shift hooks create is from coaching your AI to constraining it. Anything you find yourself repeating — "please run the tests," "format that," "don't edit the migration files" — is a candidate to stop being a request and start being a rule the harness enforces for you. You are not making Claude smarter; you are making the environment around it reliable. And reliability, not cleverness, is usually what turns a neat demo into a tool you trust with real work.


Related reading