Caelan's Domain

Claude Code Guide — Part 5: Hooks — deterministic event handlers

aiclaudeclaude-codehooksautomationsettingsclaude-code-claude-md

Created: April 27, 2026 | Modified: April 27, 2026

Why hooks

Part 4 fixed the context-overflow failure mode by giving you forked-context workers. This part fixes the other one: rules-as-prose drift.

You wrote "never write to .env" in CLAUDE.md. You wrote "always run lint after edits" in a Skill. The model honors both most of the time. Most of the time is not the same as every time. Prose instructions are advisory — the model reads them, weights them, sometimes overrides them on a tired Tuesday. Anything you actually want enforced needs a different surface.

A hook is a shell command Claude Code runs deterministically at a named lifecycle event. It is not advisory. It is not the model deciding whether to apply a rule. It is the harness firing a command on your behalf and reading the exit code. Exit 2 blocks the action. Exit 0 lets it proceed.

This is also where Part 2's no-author-from-scratch rule has its one exception. Most of .claude/settings.json builds itself as you click through permission prompts; the hooks stanza inside that same file is the one section you hand-author. The model cannot guess hooks for you, the prompt UI cannot generate them, and the actions they trigger are real shell commands with real blast radius. So you write them, you read them every time you commit the file, and the prompt-generated stuff lives next to them in the same JSON.

The eight lifecycle events

Eight events are wired. Each one fires at a specific moment in the session lifecycle. Match by name in the hooks stanza:

  • PreToolUse — fires before any tool call. Exit 2 blocks the call and feeds stderr back to the model.
  • PostToolUse — fires after a tool call returns. Useful for lint, format, validation, edit receipts.
  • UserPromptSubmit — fires when you submit a prompt. Useful for injecting standing context.
  • Stop — fires when the main session stops. Useful for end-of-session checks.
  • SubagentStop — fires when a subagent finishes. Useful for validating its report.
  • SessionStart — fires when a session begins. Useful for printing a status line.
  • SessionEnd — fires when a session terminates.
  • Notification — fires when Claude Code surfaces a notification to you.

The canonical reference, including any new events added since this article was written, is at docs.claude.com/en/docs/claude-code/hooks. Read it once before authoring; the article is short and the schema is precise.

Each event takes a list of hook entries. Each entry has a matcher — a regex string tested against the tool name (for PreToolUse and PostToolUse) or left empty for events with no tool — and one or more commands.

The JSON payload

Claude Code feeds the hook a JSON payload on stdin. Read it with jq from the hook script. The fields you reach for most are tool_name, tool_input.file_path, tool_input.command, and cwd.

{
  "session_id": "abc123",
  "transcript_path": "/Users/you/.claude/projects/<hash>/transcript.jsonl",
  "cwd": "/Users/you/code/your-project",
  "hook_event_name": "PostToolUse",
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/Users/you/code/your-project/lib/markdown.ts",
    "content": "…the new file content…"
  },
  "tool_response": {
    "success": true,
    "filePath": "/Users/you/code/your-project/lib/markdown.ts"
  }
}
Exit code 2 blocks the tool call and feeds the hook's stderr back to the model. Exit 0 lets the call proceed. Any other non-zero exit surfaces the error but does not block. exit 2 is how you say "do not let this happen."

Author your first hook

Two hooks, both small, both real. The first runs after every Write or Edit and prints a short confirmation so you can see the hook is firing. The second runs before any Bash call and refuses any command that touches .env — the rule-as-prose example from the opening, now enforced.

These go in the hooks stanza of .claude/settings.json. The rest of the file (permissions, model, env) is what Claude Code built for you in Part 2; the hooks block is what you add.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '\"edited \" + .tool_input.file_path' >&2"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -e 'select(.tool_input.command | test(\"\\\\.env\")) | error(\"refused: command touches .env\")' >/dev/null 2>&1 && exit 2 || exit 0"
          }
        ]
      }
    ]
  }
}

The PostToolUse hook reads stdin, extracts tool_input.file_path, and prints edited /path/to/file.ts to stderr. Stderr surfaces in the session UI without changing what the model sees. Useful as a quiet receipt every time a write lands.

The PreToolUse hook checks tool_input.command for the literal string .env. If found, jq -e exits non-zero, the && branch fires, the script exits 2, and Claude Code blocks the Bash call. The model sees the stderr message and tries something else. If .env is not in the command, the script exits 0 and the call proceeds untouched.

This is the line "never write to .env" doing actual work. The model can no longer disregard it on a tired Tuesday.

Test the hook

Trigger each hook deliberately so you can see it fire and confirm the wiring before you trust it on real work.

# 1. Confirm the PostToolUse hook fires.
#    Ask Claude Code to make a tiny edit:
#
#      "Add a blank line at the end of README.md."
#
#    After the Edit tool returns, you should see in the session UI:
#
#      edited /full/path/to/README.md
#
#    If you do not see it, check that `jq` is on PATH and that the hook
#    JSON in settings.json parses (run `jq . .claude/settings.json`).

# 2. Confirm the PreToolUse hook blocks .env access.
#    Ask Claude Code to run a command that touches .env:
#
#      "Run `cat .env` to show me the environment file."
#
#    The Bash call should be blocked with the message
#    `refused: command touches .env`, and the model should pivot
#    (apologize, suggest an alternative, or stop).
#
#    If the command runs anyway, the matcher did not catch it — check
#    that `matcher` is the exact string `Bash` and that the jq filter
#    matches the actual `.tool_input.command` field on a real payload.

If both checks pass, the hooks are real. They will fire on every matching tool call from now on, including in subagents — hook scope follows the project, not the session.

When to reach for a hook

Three patterns cover most real hook authorship.

Validation that has to happen. Lint after every edit. Type-check on every save. Tests after every commit. Any check you have ever forgotten to run manually is a candidate. A PostToolUse hook with matcher: "Edit|Write" running pnpm lint --filter affected is fifteen lines of JSON and removes a whole category of follow-up turn.

Refusals you cannot afford to leave to prose. The .env block above. Any Bash command pattern you treat as a hard line — rm -rf outside specific directories, anything that writes to /etc, anything that pipes a curl to a shell. Prose says "do not"; a hook makes it actually impossible.

End-of-session sanity. A Stop hook that prints the diff next to the brief, or runs the test suite one last time, or posts a summary somewhere. The lightweight version of CI that fires before you walk away from a session.

The pattern that earns hooks their place: anything you would forget to do, or anything you would override under pressure, belongs in a hook. The hook does not get tired and does not negotiate.

Pointer line in CLAUDE.md

The ## Skills, subagents, hooks (pointers) anchor is now fully authored. Replace the hooks placeholder you left in Part 4 with concrete examples:

## Skills, subagents, hooks (pointers)

- Skills live in `.claude/skills/`. See `house-style/` for the reference Skill that shapes prose, and `commit/` for the task Skill behind `/commit`.
- Subagents live in `.claude/agents/`. See `repo-researcher.md` for read-only research; delegate to it via the Task tool.
- Hooks live in the `hooks` stanza of `.claude/settings.json`. The PreToolUse hook blocks Bash commands that touch `.env`; the PostToolUse hook prints an edit receipt to stderr.

That anchor is now load-bearing in the way Part 1 promised: every primitive in the on-disk workspace has a one-line pointer the next reader can follow to the artifact.

What just changed

The on-disk workspace is complete in the sense that nothing else needs to be authored before you can do real work in it.

  • CLAUDE.md at the root, with the five anchors from Part 1, all of them now real.
  • .claude/settings.json with the permissions Claude Code built and you audited in Part 2, plus a hooks block you hand-authored that fires deterministically on real lifecycle events.
  • .claude/skills/ with the reusable procedures from Part 3.
  • .claude/agents/ with the forked-context workers from Part 4.

The two failure modes Part 3 surfaced are both fixed. Long Skills move to subagents and stop flooding the main context. Rules you want enforced move to hooks and stop being advisory.

What is next

Part 6 — The built-in tool inventory catalogues the tools Claude Code already has on hand — Read, Write, Edit, Bash, TodoWrite, AskUserQuestion, and the rest — so you know what the model is reaching for when it surfaces a tool call in your session. Knowing the inventory is what makes the next phase, the four-phase method, concrete.