Skip to content
Agentic Control Plane

Claude Code Hooks Reference — Events, Decisions, Scopes, and What Survives Bypass Mode

David Crowe David Crowe · · Updated · 12 min read
claude-code hooks permissions reference
Share X HN LinkedIn

Just want the answer? A PreToolUse hook is a command Claude Code runs before every tool call. It reads JSON on stdin and blocks the call with exit code 2 or with a JSON deny. It fires in every permission mode. One entry in ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash|Edit|Write|mcp__.*",
        "hooks": [ { "type": "command", "command": "node ~/.acp/govern.mjs", "timeout": 10 } ] }
    ]
  }
}

Install the ACP hook in one command →  ·  what it writes →  ·  free up to 5 agents

Claude Code’s hook system is the widest control surface of any coding agent: every built-in tool and every MCP tool, allow, deny, ask, and input rewriting, in every permission mode. It’s also the surface people describe wrong most often, usually in the same sentence: “the bypass flag turns hooks off.” It doesn’t. This is the reference for what hooks are, what they receive, what they can say back, and where they stop.

Where hooks live

Hooks are declared in settings, and settings have scopes:

Scope File Notes
User ~/.claude/settings.json Applies to every project on the machine; where the ACP installer writes
Project .claude/settings.json Checked in, shared with the repo
Project (local) .claude/settings.local.json Per-machine overrides, not committed
Managed Organization-deployed settings Cannot be overridden by the scopes above
Plugin hooks/hooks.json in the plugin Ships with the plugin
Skill / subagent Frontmatter Scoped to that skill or subagent

Two managed-settings switches matter for a fleet: allowManagedHooksOnly runs only the hooks in the managed scope and ignores the rest, and disableAllHooks turns every hook off along with the custom status line. Both are files on the endpoint. That is the honest limit of any client-side control: someone with write access to the config can remove it, which is why a team rolls hooks out from managed settings and keeps the policy decision somewhere the endpoint can’t edit.

The events

Claude Code fires hooks on far more than tool calls. The current list: SessionStart, Setup, UserPromptSubmit, UserPromptExpansion, PreToolUse, PermissionRequest, PermissionDenied, PostToolUse, PostToolUseFailure, PostToolBatch, Notification, MessageDisplay, SubagentStart, SubagentStop, TaskCreated, TaskCompleted, Stop, StopFailure, TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove, PreCompact, PostCompact, PreModelSwitch, PostModelSwitch, Elicitation, ElicitationResult, SessionEnd.

For control, two of them do the work. PreToolUse runs before a tool executes and can stop it. PostToolUse runs after, sees the result, and is where output scanning lives. The rest are observation and lifecycle. PermissionRequest is worth knowing: it fires when Claude Code is about to show a permission prompt, which is a different moment from “about to run a tool.”

The PreToolUse contract

Input, as JSON on stdin:

{
  "hook_event_name": "PreToolUse",
  "session_id": "…",
  "tool_use_id": "…",
  "tool_name": "Bash",
  "tool_input": { "command": "git push --force origin main" },
  "permission_mode": "default",
  "cwd": "/Users/you/repo",
  "transcript_path": "/Users/you/.claude/projects/…/session.jsonl"
}

tool_input is the tool’s arguments as the model produced them: a command for Bash, a file_path and content for Edit and Write, the tool’s own schema for MCP tools. permission_mode tells the hook what mode the session is in, including bypassPermissions, which is how you know hooks run there.

Output, by exit code or JSON:

You return Effect
Exit 0, no stdout No decision; the normal permission flow applies
Exit 2, reason on stderr Blocked; the reason goes back to the model
Exit 0 + JSON permissionDecision: "deny" Blocked, with permissionDecisionReason shown to the model
Exit 0 + JSON permissionDecision: "allow" Runs without a prompt
Exit 0 + JSON permissionDecision: "ask" Forces a prompt, even for an allowed tool
Exit 0 + JSON updatedInput: {…} The call runs with the rewritten arguments

The JSON shape is {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "…"}}. A systemMessage at the top level is shown to the user. A fourth value, defer, exists for print mode under the Agent SDK. The older top-level decision: block form is not what the current docs describe; use hookSpecificOutput.

Matchers narrow which tools a hook sees. Plain names (Bash), alternation (Edit|Write), regex (mcp__.*). MCP tools are named mcp__<server>__<tool>, so mcp__github__.* is one server and mcp__.* is every MCP tool the session has. No matcher means every call.

Timeouts. Command hooks default to ten minutes; set timeout per hook to bring that down. A hook that exceeds its timeout is treated as no decision, which for PreToolUse means the normal permission flow applies.

A minimal working hook

Deny a force-push to main, allow everything else:

#!/usr/bin/env node
let raw = ""; for await (const c of process.stdin) raw += c;
const ev = JSON.parse(raw);
const cmd = ev.tool_name === "Bash" ? String(ev.tool_input?.command ?? "") : "";
if (/git\s+push\b.*(--force|-f)\b.*\bmain\b/.test(cmd)) {
  process.stdout.write(JSON.stringify({ hookSpecificOutput: {
    hookEventName: "PreToolUse", permissionDecision: "deny",
    permissionDecisionReason: "force-push to main is blocked by policy" } }));
}

Register it under PreToolUse with a Bash matcher and it holds in every mode. It is also exactly as good as the regex, which is the limit of a hand-written hook: command strings have many spellings, and the rule lives on one machine.

What the ACP hook does with the same contract

The one-command installer registers PreToolUse and PostToolUse entries pointing at ~/.acp/govern.mjs, with ACP_CLIENT set so the row records which harness made the call. Three things it does that the minimal hook can’t:

  • Classifies instead of matching. The command is classified (Bash.git, Bash.rm, Bash.curl to a host) before the rule is looked up, so the rule is about the action, not the spelling.
  • Reads the tier off the mode. permission_mode maps to an agent tier: auto is treated as a subagent, bypassPermissions as a background agent, anything else as interactive. Rules are written per tier, so the policy for an unattended session is the one an unattended session gets.
  • Budgets itself. Four seconds to a decision. Interactive sessions fail open with a loud [ACP] UNGOVERNED line so a slow gateway never stops you working; unattended tiers stay blocked until the gateway answers, because nobody is there to notice the gap.

In --local mode the same hook calls an on-device engine instead of the gateway: policy in ~/.acp/policy.json, every decision in ~/.acp/audit.jsonl, no account. What the installer writes, file by file.

Bypass mode

--dangerously-skip-permissions puts the session in bypassPermissions. That auto-allows everything Claude Code’s own permission system would have asked about. It does not disable hooks: PreToolUse fires before any permission-mode check, in every mode, and a hook deny blocks the call under the flag. Deny rules at every settings scope and the rm -rf / circuit breaker also still apply. What the flag removes is the human confirmation, which makes the deny rules and hooks you wrote beforehand the whole boundary. What the flag actually turns off.

Subagents

Tool calls inside a subagent pass through the same PreToolUse and PostToolUse hooks as the parent. SubagentStart and SubagentStop fire around the subagent itself, and a subagent definition can carry hooks of its own. For policy, the useful fact is that the ACP hook sees the same permission_mode on subagent calls, so a fan-out under a bypass-mode parent is governed at the background tier, not the interactive one.

Known limitations

  • Hooks see tool calls, not tokens. Cost lives on the model path; see the cost tracking reference.
  • Hooks are files on the endpoint. Write access to settings is the ability to remove them. Managed settings and allowManagedHooksOnly narrow that; a decision that lives at a gateway the endpoint can’t edit removes it.
  • A hook can only judge what it’s shown. A script the agent writes and then executes is one Bash call whose contents the hook has to classify; the deny-list bypass catalog lists the spellings.

Comparing to Codex hooks

Codex CLI’s hooks are the same shape and a narrower surface: shell first, with apply_patch and MCP on current builds, and deny as the operational decision. Both keep hooks running unattended. The Codex CLI hooks reference has the detail, and the side-by-side has the table.

Frequently asked questions

Do Claude Code hooks run with --dangerously-skip-permissions?

Yes. PreToolUse hooks fire before any permission-mode check, in every mode. The hook input even carries permission_mode: bypassPermissions. A hook that returns permissionDecision deny blocks the call under the flag; what the flag removes is the interactive prompt.

Where do Claude Code hooks live?

In settings files: ~/.claude/settings.json for the user, .claude/settings.json for the project, .claude/settings.local.json for local overrides, and managed settings for an organization. Plugins ship hooks in hooks/hooks.json, and skills and subagents can declare hooks in their frontmatter. allowManagedHooksOnly restricts execution to the managed scope.

How does a PreToolUse hook block a tool call?

Two ways. Exit code 2 with the reason on stderr, or exit 0 with JSON on stdout: hookSpecificOutput.permissionDecision set to deny and permissionDecisionReason explaining why. allow skips the prompt, ask forces one, and updatedInput rewrites the call before it runs.

Can a hook match MCP tools?
Yes. MCP tools are named mcp__server__tool, so a matcher of mcp__github__.* covers one server and mcp__.* covers all of them. Built-in tools match by name, with alternation (Edit Write) and regex allowed.
Do hooks apply to subagents?

Yes. Tool calls made inside a subagent pass through the same PreToolUse and PostToolUse hooks, and SubagentStart and SubagentStop fire around the subagent itself. Subagent definitions can also carry their own hooks.

What is the hook timeout?

Command hooks default to ten minutes, with a per-hook timeout field to shorten it. ACP’s hook budgets itself to four seconds and fails open in interactive sessions so a slow gateway never bricks the terminal; unattended tiers stay blocked until the gateway answers.

Where to read more

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
Related posts

← back to blog