Українська версія

Author: abavyka

A hook is a command Claude Code runs on its own at the right moment, not because the model decided to.

What it is

You write in the settings: on this event, run this command. An event is a moment in Claude Code’s work: before a tool call, after it, when Claude is waiting on you, when it’s done answering, session start, compaction, over thirty of them in total.

The difference from CLAUDE.md is that CLAUDE.md is advice, and the model might not follow it. A hook fires every time. That’s why I wrote earlier “the critical stuff goes in hooks”.

Most often a hook is a shell command ("type": "command"). There’s also:

  • http — a POST with the event data to your URL
  • mcp_tool — a tool call on a connected MCP server
  • prompt — one question to a model that decides “yes/no”, for when a rule can’t describe it
  • agent — a subagent that can read files and run commands on its own before deciding (experimental)

How it works

Config in three levels: event → matcher (which tool, e.g. Bash or Edit|Write) → commands.

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

This is an example from the docs: after every file edit, Prettier formats it.

As input (stdin) the hook gets JSON: tool_name, tool_input (for Bash there’s command in it, for Edit — file_path), cwd, session_id.

The hook answers with an exit code:

  • 0 — all good
  • 2 — block the action, stderr goes to Claude as the explanation of why not
  • anything else — a hook error, but the action goes ahead

So exit 1 blocks nothing. Want a ban — only exit 2.

Or the hook writes JSON to stdout. For PreToolUse that’s:

  • permissionDecision — allow, deny, ask or defer
  • updatedInput — swap the tool’s arguments before it runs
  • additionalContext — toss Claude some text next to the result

Where they live

  • ~/.claude/settings.json — all your projects
  • .claude/settings.json — the project, committed, the whole team gets the same hooks
  • .claude/settings.local.json — the project, just you
  • also in plugins (hooks/hooks.json), in the frontmatter of skills and subagents, and in managed settings from your admin

Hooks from all levels stack up instead of overwriting each other. All the ones that match an event run in parallel.

  • /hooks — see all hooks by event (view only)
  • "disableAllHooks": true — turn them all off. You can’t turn off one while keeping it in the config

Mine

  • guard-main — in this wiki’s repo, .claude/settings.json, PreToolUse on Bash. Doesn’t let Claude commit or push to main or merge a PR. It’s a workaround: on free GitHub a private repo has no branch protection
  • peon-ping — Notification, PermissionRequest, PostToolUseFailure. The Warcraft III peon’s voice when a session needs my attention. A useful gimmick
  • cc-status — iTerm2 installs it itself (Claude Code Integration). Shows the session status in the tab: working, waiting, idle. I barely use it, basically it’s just UI
  • rtk — PreToolUse on Bash, rewrites the command via updatedInput: git status → rtk git status, and Claude sees compressed output

guard-main has a wrapper in sh that blocks commit and push via exit 2 when there’s no Node 24. Without it the hook on Node 20 would crash with an error, and an error, as above, blocks nothing.

What else you can do

Examples from the docs and stuff I mentioned before:

  • Prettier after every Edit|Write — config above
  • A ban on editing .env, package-lock.json and everything in .git/ — PreToolUse on Edit|Write, the script looks at file_path and does exit 2. Claude gets the explanation and looks for another way
  • Bring back the important stuff after compaction — SessionStart with matcher compact, everything the command prints to stdout lands in the context. E.g. git log --oneline -5 or “we use pnpm, not npm”
  • Typecheck after every edit, not by asking — PostToolUse on Edit|Write with npx tsc --noEmit 1>&2 || exit 2. On PostToolUse exit 2 rolls nothing back (the file is already changed), but the tsc errors go to Claude, and it fixes them
  • Don’t let Claude go until the tests are green — a Stop hook of type agent: a subagent runs the tests, and if they fail, Claude keeps working
  • Output filter — the hook cuts everything but ERROR out of a 10,000-line log before Claude sees it. Hundreds of tokens instead of tens of thousands. rtk is exactly about this

Gotchas

  • A hook isn’t a lock. A hook timeout or error doesn’t block the action unless onFailure: "block" is set (since 2.1.295). For a hard allow/deny there are permissions
  • On the flip side, a deny from a hook blocks even in bypassPermissions
  • Two hooks with updatedInput on the same tool — the one that finished last wins, i.e. a random one
  • Files you added yourself via @ go into the prompt without a tool call, so PreToolUse on Read doesn’t see them
  • A Stop hook that blocks 8 times in a row without a single tool in between gets cut off by Claude Code, and it ends the answer
  • A hook runs any command with your permissions. Hooks from someone else’s repo’s .claude/settings.json work in claude -p even without the trust dialog. Cloned it — take a look at what’s in the hooks

Read more