Hooks
What a hook is
Section titled “What a hook is”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.
Why you’d want one
Section titled “Why you’d want one”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 formatters —
PostToolUseon file edits runsprettier/eslint/ruffand 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 start —
SessionStarthook that runsgit status/git log -5and feeds the output in, so the agent always knows the current branch state. - Secret scanning —
PreToolUseon writes that scans content for API keys before they hit disk. - Compaction control —
PreCompacthook 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.
The lifecycle
Section titled “The lifecycle”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 closesTwo 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.
A worked example: blocking pushes to main
Section titled “A worked example: blocking pushes to main”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: PreToolUsematch: 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 fixmodel: 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 normallyNotice 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.
Why this and not…
Section titled “Why this and not…”| You want to… | Reach for | Not |
|---|---|---|
| Make a rule unbreakable (the model literally cannot do X) | Hook | Memory rule |
| Give context the model uses on every turn | Rules | Hook |
| Encode a procedure the model picks up when relevant | Skill | Hook |
| Restrict what tools/commands are allowed at all | Permissions | Hook |
| Run a side effect (lint, notify, log) on a CLI event | Hook | Skill |
| Isolate noisy work in its own context | Subagent | Hook |
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.
How it works in each tool
Section titled “How it works in each tool”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.
Configuration: Codex hooks load from either an inline [hooks] table in config.toml or a standalone hooks.json, at user (~/.codex/) and project (.codex/) levels. Enterprise admins can also ship managed hooks via requirements.toml.
Events: PreToolUse, PostToolUse, PermissionRequest, SessionStart, UserPromptSubmit, Stop.
Hook actions: only type = "command" handlers actually run today (the schema parses prompt and agent types but skips them). The script receives a JSON event payload on stdin (session_id, cwd, hook_event_name, model, permission_mode, plus a turn_id for turn-scoped events).
Example (~/.codex/config.toml):
[[hooks.PreToolUse]]matcher = "Bash"
[[hooks.PreToolUse.hooks]] type = "command" command = "scripts/check-bash-command.sh"opencode’s hook model is plugin-based. You write a JS/TS plugin module that exports a hook map, and opencode wires the hooks in.
A plugin function receives a context object (project, client, $, directory, worktree) and returns an object whose keys are event names:
export default ({ project, client, $ }) => ({ 'file.edited': async (event) => { await $`pnpm lint ${event.path}`; },});Available events include command.executed, file.edited, file.watcher.updated, LSP events, message events, permission.asked, permission.replied, session/server events, shell/tool execution events (including env injection), and TUI prompts/commands/notifications.
The event taxonomy is richer than Claude Code’s, but you need to author a plugin to use any of it — there’s no settings.json-style declarative hook list.
Cursor has a deterministic Hooks primitive (introduced in Cursor 1.7). Hooks are spawned subprocesses that communicate via stdin/stdout JSON.
Config files:
- User:
~/.cursor/hooks.json - Project:
<repo>/.cursor/hooks.json - Enterprise / Team: platform-managed and dashboard-distributed
Schema: {"version": 1, "hooks": { "<event>": [{ "command": "..." }] }}.
Event surface is the broadest in the foundations set — 21+ events, wider than Claude Code’s.
Agent hooks: sessionStart, sessionEnd, preToolUse, postToolUse, postToolUseFailure, subagentStart, subagentStop, beforeShellExecution, afterShellExecution, beforeMCPExecution, afterMCPExecution, beforeReadFile, afterFileEdit, beforeSubmitPrompt, preCompact, stop, afterAgentResponse, afterAgentThought.
Tab hooks (inline completion): beforeTabFileRead, afterTabFileEdit.
App lifecycle: workspaceOpen.
Enforcement: exit codes and JSON output drive policy — exit 0 + JSON for nuanced responses, exit 2 to block (equivalent to "permission": "deny"); any other non-zero is fail-open. Hook responses can return "permission": "allow" | "deny" | "ask" plus optional user_message and agent_message fields.
The combination of beforeShellExecution, beforeReadFile, and beforeMCPExecution covers most policy-enforcement use cases (secret scanning, command allowlists, audit logging) — beforeReadFile and beforeMCPExecution have no direct analog in Claude Code or Codex.
A separate Third-Party Hooks doc covers reusable hook scripts.
There is no first-class hooks primitive in the VS Code Chat IDE surface as a stable feature — VS Code Agent hooks are in preview as of mid-2026 (Unverified beyond preview status). For now, treat Copilot in the IDE as not having the deterministic-gate primitive that Claude Code, Codex, and Cursor expose.
Hook events that the preview / other surfaces expose:
sessionStart— agent session begins or resumessessionEnd— session completes or is terminateduserPromptSubmitted— every user promptpreToolUse— fires before any tool call; can approve or deny the call (the deterministic gate)
Surfaces that support hooks today:
- VS Code — Agent hooks in preview (mid-2026)
- Copilot CLI — file-based hooks
- Copilot Coding Agent (“cloud agent hooks”) — configured per-repo
- Copilot SDK — programmatic hooks for extension authors
preToolUse is the powerful one where supported: it runs an external command before any tool invocation, receives the tool call payload, and decides allow/deny — the policy-enforcement primitive.
copilot-setup-steps.yml is not a hook. It’s a dedicated GitHub Actions workflow at .github/workflows/copilot-setup-steps.yml that runs once per Coding Agent task before the agent starts, to install dependencies and prep the sandbox. Use it for environment prep on the Coding Agent surface only — it doesn’t gate runtime tool calls and doesn’t apply to VS Code Chat.
Comparison
Section titled “Comparison”| Aspect | Claude Code | Codex | opencode | Cursor | Copilot |
|---|---|---|---|---|---|
| Where defined | settings.json | config.toml [hooks] table or hooks.json | JS/TS plugin module | hooks.json | Per-surface (CLI files / Coding Agent repo / VS Code preview) |
| Declarative vs code | Declarative | Declarative | Code (JS/TS) | Declarative (commands) | Declarative |
| Event taxonomy | ~10+ named events | PreToolUse, PostToolUse, PermissionRequest, SessionStart, UserPromptSubmit, Stop | Richest among the CLIs (plugin events) | 21+ events incl. beforeReadFile, beforeMCPExecution, beforeShellExecution | sessionStart, sessionEnd, userPromptSubmitted, preToolUse |
| Action types | Shell / HTTP / prompt / subagent | Shell (type = "command") | JS code | Subprocess (stdin/stdout JSON) | External command |
| Output lands in context | Yes | Yes — hook script emits JSON on stdout with fields like additionalContext (PreToolUse) or systemMessage; exit 2 + stderr blocks the action | Plugin-controlled | Yes — JSON response with user_message / agent_message | Yes (where supported) |
| Layered (project + user + managed) | Yes — all merge | Yes (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) |
Name collisions
Section titled “Name collisions”- 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
beforeReadFileandbeforeMCPExecutionhave 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.ymlis 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.