HookStack
All guides

What Are Claude Code Hooks? A Practical Guide

7 min read · Reviewed 2026-06-12

Claude Code hooks are small programs that run automatically at fixed points in an AI coding session — before a tool runs, after a file is written, when the agent stops. They turn “please remember to…” into a guarantee the model cannot skip.

This guide explains what a hook actually is, shows you a complete working hook and the `settings.json` that registers it, walks through the JSON Claude Code hands the script on stdin, and explains why hooks beat prompt instructions for anything that must always happen.

What is a Claude Code hook?

A Claude Code hook is any executable command registered in .claude/settings.json and bound to a lifecycle event. When that event fires, Claude Code runs your command in a separate process, passes it context as JSON on stdin, and reads its stdout (and exit code). The model itself never executes the hook and cannot choose to skip it.

A hook does not have to be written in any particular language — it is just a command, so a Bash script, a Python file, or a compiled binary all work. HookStack standardizes every hook on a Node.js .mjs file for one reason: Node is the only runtime Claude Code guarantees on every platform it runs on. A .mjs hook runs identically on macOS, Linux, and Windows with zero extra setup, which is exactly what you want from a guardrail that has to be there every time.

Because the command runs outside the model, it is deterministic: the same event always triggers the same logic. That is the core property that makes hooks useful for guardrails, gates, and automation.

What does a complete hook look like?

Here is a full, runnable PreToolUse hook. It reads the tool call from stdin, blocks any Bash command containing rm -rf /, and otherwise stays silent. This is the exact pattern every hook in the HookStack catalogue follows — a pure run() function plus a small entry guard.

// .claude/hooks/block-rm-rf.mjs
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'

export function run(input) {
  if (input.tool_name !== 'Bash') return null
  const command = input.tool_input?.command ?? ''
  if (/rm\s+-rf?\s+\/(?:\s|$)/.test(command)) {
    return { decision: 'block', reason: 'rm -rf / is blocked. Run it manually if intentional.' }
  }
  return null // null = allow, hook stays silent
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const input = JSON.parse(readFileSync(0, 'utf8')) // read stdin (fd 0)
  const result = run(input)
  if (result) process.stdout.write(JSON.stringify(result))
}

The script reads the entire stdin payload with JSON.parse(readFileSync(0, "utf8")), decides what to do, and — only if it wants to block — writes { "decision": "block", "reason": "…" } to stdout. Returning nothing means “allow”. Keeping the logic in a pure run() function (separate from the stdin/stdout marshalling) is what makes the hook unit-testable.

How do I register a hook in settings.json?

A script on disk does nothing until you wire it to an event in .claude/settings.json. You declare the event, an optional matcher (which tool it applies to), and the command to run. Use $CLAUDE_PROJECT_DIR so the path resolves regardless of where Claude Code is launched.

// .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/block-rm-rf.mjs"
          }
        ]
      }
    ]
  }
}

The structure nests three levels: the event name (PreToolUse), one or more matcher groups, and the list of commands to run. The matcher filters by tool name — "Bash" runs the hook only on Bash calls, "Write|Edit" on either, "*" (or omitting it) on every tool.

What JSON does a hook receive on stdin?

Every hook is handed a JSON object on stdin. A few fields are common to all events; tool events add the tool name and its input. The block-rm-rf hook above reads tool_name and tool_input.command from this payload:

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/Users/you/project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf / --no-preserve-root"
  }
}

PostToolUse hooks get the same shape plus a tool_response field with the tool’s result. UserPromptSubmit hooks receive { "prompt": "…" }. SessionStart hooks receive a source field (startup, resume, clear, compact). The full per-event payload reference is in the official docs linked below.

How does the hook lifecycle work?

Claude Code exposes a set of lifecycle events. The most common ones are:

  • PreToolUse — runs before a tool (Bash, Write, Edit, WebFetch…) executes. It can block the action by returning { "decision": "block", "reason": "…" } on stdout.
  • PostToolUse — runs after a tool completes, with the tool result available. Used for auto-formatting, linting, and type-checking.
  • UserPromptSubmit — fires on each prompt. Whatever it prints to stdout becomes extra context for that turn — used to inject project conventions or the current date.
  • Stop — fires when Claude finishes a task. Used for tests, changelogs, and quality gates.
  • SessionStart / SessionEnd — fire at session boundaries. Used for git context injection and audit logs.
  • Notification, SubagentStop, PreCompact — fire on user-input requests, subagent completion, and before context compaction.

PreToolUse hooks are the only ones that can stop an action before it happens. Everything else reacts to or annotates what has already occurred.

Why use hooks instead of prompt instructions?

Prompt instructions — including a CLAUDE.md file — are probabilistic. The model reads them and usually follows them, but it can drift, forget, or decide an exception applies. For anything that must happen every single time, “usually” is not good enough.

Hooks execute unconditionally on matching events. There is no drift, no hallucination, and no forgotten rule. They are also cheap on context: the command runs in its own process and nothing it computes enters the model’s context window unless the hook explicitly prints text back (a blocking reason, or context injected by a UserPromptSubmit hook). That returned text is the only part that costs tokens.

What can you do with Claude Code hooks?

A few high-value patterns the HookStack catalogue covers:

  • Block a leaked API key before a shell command runs (PreToolUse).
  • Run your test suite when the agent says it is done (Stop).
  • Auto-format and lint every file the moment it is written (PostToolUse).
  • Inject your project conventions and the current date on every prompt (UserPromptSubmit).
  • Refuse edits on the main branch until you create a feature branch (PreToolUse).

How do I install a Claude Code hook?

You can write your own (see the first-hook tutorial below), or install production-ready hooks from HookStack in one command. Browse the catalogue, select the hooks you want, and run the generated command in your project root:

npx hookstack-cli@latest install

The CLI writes the scripts to .claude/hooks/ and patches your .claude/settings.json — nothing else is touched. Every hook in the catalogue is a real .mjs that HookStack runs and unit-tests on its own repository, so you install the exact code that runs in production.

Frequently asked questions

Do Claude Code hooks have to be written in JavaScript?
No. A hook is any executable command — Bash, Python, or a binary all work. HookStack standardizes on Node.js .mjs files because Node is the one runtime Claude Code guarantees on every platform, so the same hook runs on macOS, Linux, and Windows.
Do hooks consume tokens from my context window?
Only what they print back. A hook runs in a separate process; nothing it computes reaches the model unless it returns text — a blocking reason or context injected by a UserPromptSubmit hook. A silent hook costs zero tokens.
Can a hook block Claude from doing something?
Yes — a PreToolUse hook can block the action by writing { "decision": "block", "reason": "…" } to stdout before the tool runs.
Where do hooks live in my project?
Scripts live in .claude/hooks/ and are referenced by event and matcher in .claude/settings.json, usually as "node $CLAUDE_PROJECT_DIR/.claude/hooks/<name>.mjs".

Related hooks

Sources

Read next