In Plain Words

Put "never edit .env" in CLAUDE.md and the model reads the rule. Following it is the model's call. Register a hook that rejects edits to .env and the edit never runs, whatever the model chose.

A coding agent works in a loop: the model proposes a tool call (run a shell command, edit a file, call an MCP server), the program around the model executes it, and the result goes back to the model. That surrounding program is the harness. The harness emits named events at fixed points in the loop, such as PreToolUse before a tool runs or Stop when the agent finishes a response. A hook is your code attached to one of those events.

Three words carry most of the vocabulary. The event is the point in the loop. The matcher narrows which occurrences trigger the hook, usually by tool name, so Edit|Write means file edits only. The handler is what runs: in most setups a shell command, though Claude Code also accepts an HTTP endpoint, an MCP tool, a single model prompt or a verifying subagent. Claude Code calls that last handler type an "agent hook" too, which is narrower than the general term (see Common Confusions).

How It Works

Claude Code's documentation gives the clearest end-to-end contract. Other agents follow the same shape with different names.

  1. An event fires, for example PreToolUse just before a tool call executes.
  2. The harness checks each hook registered for that event against its matcher. Matching hooks run in parallel.
  3. Each handler receives the event as JSON on stdin: session_id, cwd, hook_event_name, and for tool events tool_name and tool_input.
  4. The handler answers with an exit code or a JSON object. Exit 2 blocks, and Claude sees stderr as the reason. Exit 0 means no objection rather than approval, so the call still goes through the normal permission flow. Any other non-zero code is a non-blocking error and the action goes ahead.

Instead of an exit code, a PreToolUse hook can exit 0 and print JSON with permissionDecision set to allow, deny, ask or defer, and can rewrite the tool input with updatedInput before it runs. When several hooks disagree, deny wins.

This is the protected-files example from the Claude Code hooks guide. First the registration in .claude/settings.json, then the script it points at:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# protect-files.sh

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Normalize Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"

PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0

We fed the script three hand-written payloads on macOS 27.2.1 with bash 3.2.57 and jq 1.7.1. Claude Code 2.1.322 was installed but not invoked: we did not run the hook inside a live session, so what Claude Code does with exit 2 is docs-verified, not observed.

$ echo '{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/repo/.env"}}' | ./.claude/hooks/protect-files.sh; echo "exit=$?"
Blocked: /repo/.env matches protected pattern '.env'
exit=2
$ echo '{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/repo/src/app.ts"}}' | ./.claude/hooks/protect-files.sh; echo "exit=$?"
exit=0
$ echo '{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/repo/.envrc"}}' | ./.claude/hooks/protect-files.sh; echo "exit=$?"
Blocked: /repo/.envrc matches protected pattern '.env'
exit=2

Two things show up. The substring match blocks .envrc too, which is a false positive you would tighten with an exact basename check. And the matcher only covers the Edit and Write tools: the Agent SDK docs warn that if the model writes .env through a Bash command, the hook does not run.

Anything that must not happen belongs in PreToolUse. A PostToolUse hook runs after the tool has executed, so it can report or reformat the result but cannot undo the action.

Why It Matters

Hooks are the deterministic layer of an agent. Claude Code's guide says they make "certain actions always happen rather than relying on the LLM to choose to run them." A deny from a PreToolUse hook blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions, because the hook fires before any permission-mode check. The reverse does not hold: a hook's allow cannot override deny rules in settings.

That makes hooks the place for policy. The Claude Code docs use them to block edits to secrets, run Prettier after every edit and make an agent check that tests pass before it stops. Our guide to locking down Claude Code egress pairs PreToolUse hooks with the sandbox, and the Claude Code email leak shows why a hook is the control you reach for when a fix must not depend on the model's judgement.

Hooks Across Coding Agents

There is no shared standard across coding agents. The same "before a tool runs" event goes by different names and fails differently:

AgentBefore-tool eventWhere hooks liveHow a hook blocks
Claude CodePreToolUse.claude/settings.jsonExit 2 or deny. Other non-zero exits let the call through.
OpenAI CodexPreToolUsehooks.json or [hooks] in config.toml under ~/.codex/ or <repo>/.codex/Exit 2 or deny. ask is not supported yet.
GitHub Copilot CLI, VS Code (preview)preToolUseCopilot CLI: .github/hooks/*.json. VS Code: not covered in the docs we readdeny, exit 2, or any other non-zero exit (fail-closed). Timeouts fail open.
CursorpreToolUse, beforeShellExecution, beforeMCPExecution.cursor/hooks.jsonJSON "permission": "deny"
Gemini CLIBeforeTool.gemini/settings.jsonExit 2 aborts the action, stderr is the reason.
KiroPre Tool UseNot covered in the docs we readBlocks tool execution unless preconditions are met; exit behaviour not covered here.

Read the last column before you port a hook. A guard that crashes with exit 1 is ignored by Claude Code and treated as a denial by Copilot. Cursor's and Gemini CLI's docs, as we read them, don't say what a crashing hook does. VS Code puts it plainly: "Sharing a hook file location does not establish runtime compatibility." Codex's docs add a caveat that applies to all of them: "Treat tool hooks as a useful guardrail, not a complete enforcement boundary."

Common Confusions

TermWhat it isHow it differs
Claude Code type: "agent" hookOne handler type that spawns a subagent with Read, Grep and Glob to verify a condition before returning a decisionA kind of hook, not the general term. Experimental, 60-second default timeout, up to 50 tool-use turns. The docs recommend command hooks for production.
Kiro agent hooksKiro's product name for its hooks, which run shell commands or inject agent promptsSame idea, wider trigger set: Kiro also fires on file save, create and delete, not only on agent lifecycle events.
Instruction files and skillsCLAUDE.md, AGENTS.md and skills are text the model readsThe model can ignore text. A hook runs in the harness whether the model agrees or not.
Permission rulesStatic allow and deny lists in agent settingsHooks sit on top of rules. In Claude Code a hook's allow cannot bypass a settings deny rule, while a hook's deny holds in every permission mode.
WebhooksAn HTTP callback to a URLA delivery mechanism. Claude Code's HTTP hooks POST the event JSON to a URL, but the hook is the lifecycle point, not the transport.