Claude Code Hooks Reference — Events, Decisions, Scopes, and What Survives Bypass Mode
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.curlto 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_modemaps to an agent tier:autois treated as a subagent,bypassPermissionsas 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] UNGOVERNEDline 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
allowManagedHooksOnlynarrow 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
Bashcall 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
- Claude Code hooks guide — the upstream reference this page checks against
- Claude Code cost tracking reference — the other plane
- Claude Code headless and approvals — what a hook
askmeans when nobody is on stdin - Which Claude Code tools to deny out of the box — a default posture argued from blast radius
- Set up permissions across your team’s coding agents — managed settings and one policy for the fleet