Skip to content

Hooks

A hook is a piece of code or a command that fires automatically when a specific lifecycle event happens inside the CLI — before a tool call, after a file edit, when a session starts, when the model wants to compact context, when a permission is requested. You declare what event and what to run, and the CLI takes it from there.

The defining property: a hook is deterministic. It always fires on its event. It doesn’t depend on the model deciding to use it, noticing a description, or remembering a CLAUDE.md line. Where a memory rule is a request and a skill is opt-in by the model, a hook is enforcement by the runtime.

You told the agent, in CLAUDE.md, “never push to main.” You said it again at the start of the session: “don’t push to main, the release branch is release/2026.04.” Two hours later, mid-flow, you ask it to “ship the fix.” It runs git push origin main.

The agent didn’t ignore you on purpose. It read the instruction, weighed it against the goal, and the goal won. That’s what models do — they’re persuadable, including by their own reasoning. A CLAUDE.md rule is a strong suggestion to a probabilistic system. And probabilistic systems, sometimes, push to main.

The only way to make a rule unbreakable is to take the decision away from the model. A hook on PreToolUse that pattern-matches git push.*main and blocks the call doesn’t ask the model nicely. It just doesn’t run. That’s the gap hooks close: turning “please don’t” into “can’t.”

Real-world examples of what teams put in hooks:

  • Hard guardrails — block rm -rf, block edits to .env, block writes to /etc, block pushes to protected branches. The model can attempt; the hook says no.
  • Auto-run linters and formattersPostToolUse on file edits runs prettier/eslint/ruff and feeds errors back to the agent as context it can fix.
  • Auto-run tests on touched modules — when the agent edits src/auth/*.ts, fire the auth test suite. Catches regressions inside the session, not at PR time.
  • Notifications and observability — post to Slack when a long session ends, log every shell command to a file, ping yourself when the agent’s been idle for 5 minutes.
  • Context injection at session startSessionStart hook that runs git status / git log -5 and feeds the output in, so the agent always knows the current branch state.
  • Secret scanningPreToolUse on writes that scans content for API keys before they hit disk.
  • Compaction controlPreCompact hook that snapshots the conversation to a file before the model summarises it away.

The test: if “the agent forgot” or “the agent did the thing I told it not to” is a failure mode you can’t accept, you want a hook, not a memory rule.

Hooks fire on events along the loop. The exact event names differ across tools (the comparison table below has the per-tool list), but the rough timeline is the same:

session opens
┌─────────────┐
│ SessionStart│ ── one-shot: inject context, log who/where
└─────┬───────┘
▼ ┌──────── per turn ─────────────────────────────────┐
│ │
│ ┌──────────────────┐ │
│ │ UserPromptSubmit │ ── inspect or modify your │
│ └────────┬─────────┘ prompt before model │
│ │ │
│ ▼ ┌──────── per tool call ────────┐ │
│ │ │ │
│ │ ┌─────────────┐ │ │
│ │ │ PreToolUse │ ◄── gate: │ │
│ │ └─────┬───────┘ allow, │ │
│ │ │ deny, │ │
│ │ ▼ modify │ │
│ │ [ tool runs ] │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────┐ │ │
│ │ │ PostToolUse │ ── side │ │
│ │ └─────────────┘ effects: │ │
│ │ lint, │ │
│ │ test, │ │
│ │ log │ │
│ └───────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────┘
┌──────────────┐
│ Stop │ ── notify, snapshot
└──────────────┘
session closes

Two kinds of hook live on this timeline:

  • Gating hooks (PreToolUse, equivalents) intercept before an action runs. They can allow, modify the arguments, or block outright. This is where “make a rule unbreakable” lives.
  • Reactive hooks (PostToolUse, SessionStart, Stop) run after something happens. They don’t decide anything; they react — lint the file you just wrote, fire a notification, append to a log.

A hook’s superpower is being inside the loop but outside the model’s reasoning. The model can want to push to main all it likes; the gating hook simply doesn’t let the tool call complete.

The classic. You’ve told the agent not to push to main. Three sessions in a row, it pushed to main. A PreToolUse hook on shell commands fixes it forever.

The shape, in pseudocode (real syntax in the per-tool tabs below):

event: PreToolUse
match: tool == "Bash" AND command matches /git\s+push.*\smain(\s|$)/
action: { "decision": "block",
"reason": "Pushes to main are blocked. Use a release branch." }

Walked through one turn:

you: ship the fix
model: I'll run: git push origin main
[ proposes the Bash tool call ]
┌─ PreToolUse hook fires ─────────────────────────────────┐
│ regex matches "git push origin main" │
│ hook returns: decision=block, reason="…" │
└──────────────────────┬──────────────────────────────────┘
tool call NEVER runs
model: [ sees the block + reason in its next turn ]
model: Blocked — pushes to main aren't allowed.
Pushing to release/2026.05 instead: git push origin release/2026.05
┌─ PreToolUse hook fires again ───────────────────────────┐
│ regex does NOT match — release branch │
└──────────────────────┬──────────────────────────────────┘
tool call runs normally

Notice two things. First: the hook fires between “the model proposes a tool call” and “the tool actually runs.” That’s the only place a deterministic gate can live. Second: when the hook blocks, it returns a reason the model can read on its next turn. The model isn’t punished or kicked out — it’s told no, gets the reason in its context, and adapts. The combination is what makes hooks safer than just denying via permissions — permissions say no; hooks say no and explain.

The same pattern generalises. Replace the match condition and you get: block rm -rf outside the project, block edits to .env, block npm install without a lockfile, run prettier after every write, post to Slack on Stop. Same lifecycle, same shape.

You want to…Reach forNot
Make a rule unbreakable (the model literally cannot do X)HookMemory rule
Give context the model uses on every turnRulesHook
Encode a procedure the model picks up when relevantSkillHook
Restrict what tools/commands are allowed at allPermissionsHook
Run a side effect (lint, notify, log) on a CLI eventHookSkill
Isolate noisy work in its own contextSubagentHook

Hooks are for every time event X happens, do Y, no exceptions. Skills are for when the situation looks like X, the model might decide to do Y. Memory is for the model should know X. If you’ve been frustrated that the agent “knew the rule but did it anyway,” you wanted a hook.

Configuration: hooks live in settings.json (project, user, or managed levels). All registered hooks merge — Claude Code fires every hook that matches a given event, regardless of where it was defined.

Events: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop, SubagentStop, PreCompact, Notification, and more.

Hook actions can be:

  • A shell command
  • An HTTP request
  • A prompt sent to Claude
  • A subagent invocation

Example (.claude/settings.json):

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "scripts/check-bash-command.sh"
}
]
}
}

Hook output can land in the conversation as a message — a PostToolUse hook running your linter feeds errors back to Claude as text it can read and act on.

AspectClaude CodeCodexopencodeCursorCopilot
Where definedsettings.jsonconfig.toml [hooks] table or hooks.jsonJS/TS plugin modulehooks.jsonPer-surface (CLI files / Coding Agent repo / VS Code preview)
Declarative vs codeDeclarativeDeclarativeCode (JS/TS)Declarative (commands)Declarative
Event taxonomy~10+ named eventsPreToolUse, PostToolUse, PermissionRequest, SessionStart, UserPromptSubmit, StopRichest among the CLIs (plugin events)21+ events incl. beforeReadFile, beforeMCPExecution, beforeShellExecutionsessionStart, sessionEnd, userPromptSubmitted, preToolUse
Action typesShell / HTTP / prompt / subagentShell (type = "command")JS codeSubprocess (stdin/stdout JSON)External command
Output lands in contextYesYes — hook script emits JSON on stdout with fields like additionalContext (PreToolUse) or systemMessage; exit 2 + stderr blocks the actionPlugin-controlledYes — JSON response with user_message / agent_messageYes (where supported)
Layered (project + user + managed)Yes — all mergeYes (user + project, managed available)Yes (global + project + managed)Yes (user + project + enterprise)Per-surface
Deterministic gate (allow/deny)Yes (PreToolUse)Yes (PreToolUse, exit 2)Yes (plugin permission.asked)Yes ("permission": "allow" | "deny" | "ask")Yes (preToolUse, where supported)
  • An opencode “hook” lives inside a plugin — you can’t have a hook without writing a plugin. In Claude Code, Codex, and Cursor, hooks are a standalone primitive declared in config.
  • Don’t confuse Claude Code’s PreCompact (model-level context compaction) with shell-level “pre-commit” hooks. Despite the name they have nothing in common.
  • Cursor’s beforeReadFile and beforeMCPExecution have no direct analog in any other tool in the foundations set — they let you gate file reads and individual MCP tool calls before they happen.
  • Copilot’s copilot-setup-steps.yml is sometimes called a “pre-flight hook” but it isn’t one. It’s a GitHub Actions workflow that runs once before a Coding Agent task to prep the sandbox — not a runtime gate, not part of the hook taxonomy, and not applicable to the VS Code IDE surface.