Claude Code hooks explained: PreToolUse, PostToolUse, and Stop
Every Claude Code hook event, when each fires, which can block, and the exact hookSpecificOutput fields: permissionDecision, permissionDecisionReason, updatedInput, additionalContext.
Claude Code hooks are shell commands the agent runs at fixed points in its lifecycle. A hook reads a JSON event on stdin, does whatever you want, and can return JSON on stdout to change what happens next. The three you reach for most are PreToolUse (before a tool runs), PostToolUse (after it succeeds), and Stop (when Claude finishes a turn). PreToolUse is the one that can stop a tool call before it executes, and that is the mechanism Pushary runs on.
Key takeaways
- Hooks are shell commands Claude Code runs at fixed lifecycle events, reading a JSON event on stdin and signaling back with an exit code or JSON on stdout.
- There are 33 events. The full table below lists every one, when it fires, whether it can block, and its exact
hookSpecificOutputfields. PreToolUsefires before every tool call executes and is the main place to stop one, by exiting 2 or by printing apermissionDecisionofallow,deny,askordefer.PermissionRequestcan also deny, but only when a permission dialog would appear.- Exit 2 is not a universal block. It stops the tool on
PreToolUseand the turn onStop, but onSessionStartit blocks nothing, and onPermissionDeniedthe exit code is ignored entirely. Stopfires when a turn ends andNotificationfires when Claude needs input, so both are good places to send yourself an alert.- A
Stophook can send a notification, but it cannot collect an answer: the turn is already over. Pushary attaches toPreToolUseinstead, so the tool call waits while the approve/deny decision happens on your phone, and every decision lands in an audit trail. - If you are looking for
onStartandonFinish, Claude Code does not use those names. The nearest equivalents areSessionStartandSessionEndfor a session, andStopfor the end of a single turn.
Every hook event
The list is far longer than three. Checked against the official hooks reference on 29 September 2026, there are 33 events. Most exist for logging or context injection; the ones that can actually stop something are marked below.
"Blocks" means the event can prevent the thing it fires on. The mechanism differs: some use hookSpecificOutput, some a top-level decision field, some just exit code 2.
| Event | Fires | Blocks | hookSpecificOutput fields |
|---|---|---|---|
SessionStart | Session starts or resumes | No | additionalContext, initialUserMessage, watchPaths, sessionTitle, reloadSkills |
Setup | --init-only, or --init / --maintenance in -p mode | No | none (Claude Code discards Setup JSON output) |
UserPromptSubmit | You submit a prompt, before Claude reads it | Yes, top-level decision | additionalContext, sessionTitle, suppressOriginalPrompt |
UserPromptExpansion | A typed command expands into a prompt | Yes, top-level decision | additionalContext |
PreToolUse | Before a tool call executes | Yes, permissionDecision | permissionDecision, permissionDecisionReason, updatedInput, additionalContext |
PermissionRequest | A permission dialog appears | Yes, decision.behavior | decision (behavior, updatedInput, applyRules) |
PermissionDenied | A call is denied by the auto mode classifier | Retry only | retry |
PostToolUse | After a tool call succeeds | No, the tool already ran. A top-level decision of block only gives Claude a reason | updatedToolOutput, additionalContext |
PostToolUseFailure | After a tool call fails | No, the tool already failed | additionalContext |
PostToolBatch | After a batch of parallel calls resolves | Yes, top-level decision | additionalContext |
Notification | Claude Code sends a notification | No | none |
MessageDisplay | While assistant text is displayed | Rewrites text | displayContent |
SubagentStart | A subagent is spawned | No | additionalContext |
SubagentStop | A subagent finishes | Yes, top-level decision | additionalContext |
TaskCreated | A task is created via TaskCreate | Yes, exit 2 or top-level decision | none |
TaskCompleted | A task is marked completed | Yes, exit 2 or continue: false | none |
Stop | Claude finishes responding | Yes, top-level decision | additionalContext |
StopFailure | The turn ends on an API error | No | none |
TeammateIdle | An agent team teammate is about to idle | Yes, exit 2 or continue: false | none |
InstructionsLoaded | A CLAUDE.md or .claude/rules/*.md loads | No | none |
ConfigChange | A config file changes mid-session | Yes, top-level decision | none |
CwdChanged | The working directory changes | No | watchPaths |
DirectoryAdded | A working directory is added mid-session with /add-dir or the SDK | No | none |
FileChanged | A watched file changes on disk | No | watchPaths |
WorktreeCreate | A worktree is created | Yes, any non-zero exit | worktreePath |
WorktreeRemove | A worktree is removed at exit, when a subagent finishes, or when a background session is deleted | Yes, any non-zero exit | none |
PreCompact | Before context compaction | Yes, top-level decision | none |
PostCompact | After compaction completes | No | none |
PreModelSwitch | Before a model switch you or a client asked for takes effect | Yes, permissionDecision | permissionDecision, permissionDecisionReason |
PostModelSwitch | After the session model changes, including changes Claude Code makes itself | No | additionalContext |
Elicitation | An MCP server requests user input | Yes, action | action, content |
ElicitationResult | After the user answers an elicitation | Yes, action | action, content |
SessionEnd | A session terminates | No | none |
The events below are the ones that change agent behavior, and the ones Pushary attaches to.
How a hook is wired
You register hooks in settings under a hook event, with an optional matcher to filter when they fire, and a command to run. A matcher of Bash means the hook only fires for the Bash tool. The command receives the event as JSON on stdin and signals its decision two ways: exit codes, or structured JSON on stdout.
Exit 0 means success, and Claude Code parses stdout for decision fields. Any exit other than 0 or 2 is a non-blocking error that gets logged and ignored.
Exit 2 is where people get caught out, because it is often described as "exit 2 blocks" as though that were universal. It is not. What exit 2 does depends on the event:
- On
PreToolUseit blocks the tool call and feeds stderr back to the model. - On
UserPromptSubmitit blocks the prompt, onStopit prevents the turn ending, onSubagentStopit prevents the subagent stopping, onPreCompactit blocks compaction. - On
SessionStartandSubagentStartit blocks nothing at all. It shows stderr as a hook error notice and the session or subagent carries on regardless. - On
Notificationit is simply a non-blocking error shown to you. - On
PermissionDeniedandStopFailurethe exit code is ignored outright, because the outcome has already happened.
If you need a decision rather than a signal, prefer the JSON path. It says what you mean and it does not depend on remembering which event you are in.
PreToolUse
PreToolUse fires before a tool call executes. The event carries the tool name and its arguments, so your hook sees exactly what the agent is about to do:
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/dir",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf /tmp/build" }
}To gate the call, return hookSpecificOutput with a permissionDecision:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}The permissionDecision values are allow (let it run), deny (block it), ask (escalate to the user), and defer (end the run so the tool call can be resumed later, honored only in non-interactive -p mode). Alongside it, updatedInput rewrites the tool arguments before the tool runs, and additionalContext injects text the model sees.
That deny path is real enforcement. The tool never runs. A hook that only logs can see the same command, but it cannot stop it.
PermissionRequest
PermissionRequest fires when a permission dialog would appear, which makes it the second place a decision can be settled without you touching the keyboard. It answers through hookSpecificOutput.decision rather than permissionDecision, and the shape is different enough to be worth writing out:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": { "behavior": "deny", "message": "Denied by policy for Bash(rm -rf:*)" }
}
}behavior is allow or deny, and the decision object also accepts updatedInput and applyRules.
The ordering catches people out when both hooks are registered. PermissionRequest fires after PreToolUse, and only on the ask path: if PreToolUse already returned allow or deny, the dialog never happens and this event never fires. So a handler registered on both events will see the same tool twice whenever the decision was ask, and needs to skip the tools the earlier hook already owns or it will prompt you twice for one command.
How Pushary uses PreToolUse
Pushary connects to Claude Code as a PreToolUse hook. The agent is about to run a tool, the hook reads the event on stdin, and the handler resolves the tool and its arguments against your permission policy. If the policy says allow, the hook returns allow and the agent never stops. If it says ask, the hook sends a push notification with the command and waits for your answer, then returns allow or deny based on what you tapped.
Two details matter here. First, the policy matches on the arguments inside tool_input, not just tool_name, so git status and git push --force get different treatment from the same Bash tool. Second, a proven read-only set of commands is auto-approved by default, decided from 1,721 real production questions, so reads never interrupt you. More on both in the permission policy writeup. The Claude Code wiring is in the setup guide.
Enforced gating needs a hook to attach to. CLI agents have one. The Claude Desktop connector can notify and ask through MCP, but Desktop exposes no PreToolUse hook, so it cannot block a tool call. That is a hard limit of the surface, not a setting.
There is a second case with no hook to attach to, and it catches people out. If you are shipping an agent inside your own product, there is no settings.json on the machine that matters, because the agent runs on your server and the person who should approve is your customer. Hooks cannot help you there. You call the approval directly instead: decisions.ask() blocks your agent until that specific user answers on their phone, and enroll(externalId) maps your own user id to their device. Same fail-closed behaviour as the hook path, no CLI involved. That is what human-in-the-loop for AI agents covers, with adapters for LangGraph, the Vercel AI SDK and the rest, and the Partner plan is the version you embed for end users who have no idea Pushary exists.
PostToolUse
PostToolUse fires after a tool call succeeds. It cannot stop the call, the work already ran, but it sees the result and can feed feedback back to the agent or replace the tool output. Use it for logging, formatting after an edit, or flagging a result the agent should reconsider. It runs the cleanup half of the loop, after PreToolUse has already let the call through.
Stop and UserPromptSubmit
Stop fires when Claude finishes responding. Returning a decision of block with a reason forces the agent to keep going instead of ending the turn, which is how you make a turn continue until tests pass or a phone-queued instruction is picked up.
UserPromptSubmit fires when you submit a prompt, before Claude reads it. Plain stdout from this hook is added to the prompt as context, and it carries the message text, which is how a running session can capture a real task title for the fleet board.
Hooks in the Claude Agent SDK
The Claude Agent SDK runs the same agent loop as Claude Code and uses the same event names. The TypeScript SDK exports them as HOOK_EVENTS, and in version 0.3.284, current on 29 September 2026, that list has the same 33 names as the table above. The Python SDK supports 10 of them: PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop, SubagentStart, SubagentStop, PreCompact, PermissionRequest and Notification. The difference is where a hook lives. In the SDK it is a function in your code, passed in the hooks option of query(), not a shell command in settings.json.
Each event takes a list of matchers. A matcher has an optional matcher pattern, a list of callbacks and an optional timeout in seconds. A callback receives the same input a command hook reads on stdin, and it returns the same JSON a command hook prints:
import { query, type HookCallback } from "@anthropic-ai/claude-agent-sdk"
const blockEnvReads: HookCallback = async (input) => {
if (input.hook_event_name !== "PreToolUse") return {}
const { file_path } = input.tool_input as { file_path?: string }
if (!file_path?.endsWith(".env")) return {}
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Reading .env files is blocked",
},
}
}
for await (const message of query({
prompt: "Summarise the config in this repo",
options: { hooks: { PreToolUse: [{ matcher: "Read", hooks: [blockEnvReads] }] } },
})) {
if (message.type === "result") console.log(message.subtype)
}The permissionDecision values are the same four: allow, deny, ask and defer. In the SDK, defer ends the query so you can resume it later. updatedInput and additionalContext work the same way too.
A hook is the right place for a fixed rule. When a person should decide, the SDK has a separate callback for that, canUseTool, which runs when a tool call needs permission. @pushary/claude-agent-sdk implements it: the question goes to your user's phone, and Approve or Deny comes back as the permission result. That package is for agents you ship to your own users, on the Partner plan.
Putting it together
The hook contract is small: read JSON on stdin, decide with an exit code or a JSON object on stdout. Gate with PreToolUse. Watch with PostToolUse. Stop decides whether a turn actually ends. Pushary turns the PreToolUse decision into a push notification you answer from your phone, then logs every call to an audit trail. See the Claude Code notifications page, or wire up other agents over MCP.
If you would rather not wire it yourself
Hooks are a shell contract, so anything that reaches your phone from one is something you either build or buy. Claude Code notifications is the buy side written out in full: what arrives on the phone, what you can answer from the lock screen, and what it costs. The honest comparisons are worth reading before you pick either way: Pushary vs Happy and Pushary vs Omnara cover the tools that mirror a whole session to your phone, Pushary vs HumanLayer covers approval APIs you call from your own code, and Pushary vs ntfy covers the free route where you keep the hook and add a push channel. All comparisons has the rest.
Common questions
What are Claude Code hooks?
Claude Code hooks are shell commands the agent runs at fixed points in its lifecycle. A hook reads a JSON event on stdin, does whatever you want, and can return an exit code or JSON on stdout to change what happens next. The events you reach for most are PreToolUse (before a tool runs), PostToolUse (after it succeeds), and Stop (when Claude finishes a turn).
What exit code must a PreToolUse hook return to block a tool call?
Exit code 2. A PreToolUse hook that exits 2 blocks the tool call and feeds its stderr back to the model as the reason. Exit 0 allows the call. Any other non-zero exit is a non-blocking error that is logged and ignored. For structured control you can instead exit 0 and print hookSpecificOutput JSON on stdout with a permissionDecision of allow, deny, or ask.
Which hook event fires when Claude Code finishes a turn?
Stop. It fires when Claude finishes responding, which is the end of a single turn rather than the end of the session. SubagentStop is the subagent equivalent, and SessionEnd fires when the session itself ends. Stop is also one of the events that can block: returning a decision of block with a reason forces the agent to keep going instead of ending the turn.
Getting that event to reach you away from the machine is a separate problem from the hook contract, and the Claude Code notifications page covers it. If a Stop or Notification hook is wired and no alert shows up, or it fires on every reply, Claude Code notifications not working walks through the causes.
Which hook stops Claude from reading sensitive .env files, and what tool names does it match?
A PreToolUse hook, matched against the file-reading tools: Read, plus Grep and Glob if you want to stop the file being searched as well as opened. PreToolUse fires before every tool call, so it is the one that reliably prevents the read. PermissionRequest also fires before the call, but only when a permission dialog would appear. PostToolUse cannot help, because by the time it fires the file has already been read. Match on the path inside tool_input rather than on tool_name alone, then return a permissionDecision of deny with a permissionDecisionReason.
What are the permissionDecision values for a PreToolUse hook?
allow, deny, ask, and defer. allow runs the tool, deny blocks it, ask escalates to the user, and defer ends the run so the tool call can be resumed later. Claude Code honors defer only in non-interactive -p mode and ignores it in an interactive session. They go inside hookSpecificOutput alongside permissionDecisionReason, and optionally updatedInput to rewrite the tool arguments and additionalContext to inject text the model sees.
How many hook events does Claude Code have?
Thirty-three, as of 29 September 2026. Three were added since our 26 July check: PreModelSwitch, PostModelSwitch and DirectoryAdded. Most events are for logging or context injection. The ones that can block are PreToolUse, PermissionRequest, UserPromptSubmit, UserPromptExpansion, PostToolBatch, SubagentStop, Stop, PreCompact, PreModelSwitch, ConfigChange, TaskCreated, TaskCompleted, TeammateIdle, WorktreeCreate, WorktreeRemove, Elicitation and ElicitationResult. The rest observe and cannot change the outcome.
How do you use PreToolUse hooks in the Claude Agent SDK?
Pass a function in the hooks option of query(), under PreToolUse, with an optional matcher such as Bash or Read. The function receives the same input a Claude Code command hook reads on stdin, including tool_name and tool_input, and returns the same hookSpecificOutput, so a permissionDecision of deny blocks the call. The TypeScript SDK accepts all 33 event names; the Python SDK accepts 10 of them. To ask a person rather than apply a fixed rule, use the SDK's canUseTool callback, which runs when a tool call needs permission. The example above shows the full shape.
What is hookSpecificOutput?
It is the object a hook prints on stdout to return a structured decision instead of relying on an exit code. It always carries hookEventName, and the rest of the fields depend on the event: permissionDecision and permissionDecisionReason on PreToolUse, a decision object with behavior on PermissionRequest, updatedToolOutput on PostToolUse, additionalContext on most of the observing events. Exit 0 alongside it, because a non-zero exit is interpreted before the JSON is.
Frequently asked questions
What are Claude Code hooks?
Claude Code hooks are shell commands the agent runs at fixed points in its lifecycle. A hook reads a JSON event on stdin, does whatever you want, and can return an exit code or JSON on stdout to change what happens next. The events you reach for most are PreToolUse (before a tool runs), PostToolUse (after it succeeds), and Stop (when Claude finishes a turn).
What exit code must a PreToolUse hook return to block a tool call?
Exit code 2. A PreToolUse hook that exits 2 blocks the tool call and feeds its stderr back to the model as the reason. Exit 0 allows the call. Any other non-zero exit is a non-blocking error that is logged and ignored. For structured control you can instead exit 0 and print hookSpecificOutput JSON on stdout with a permissionDecision of allow, deny, or ask.
Which hook event fires when Claude Code finishes a turn?
Stop. It fires when Claude finishes responding, which is the end of a single turn rather than the end of the session. SubagentStop is the subagent equivalent, and SessionEnd fires when the session itself ends. Stop is also one of the events that can block: returning a decision of block with a reason forces the agent to keep going instead of ending the turn. Getting that event to reach you away from the machine is a separate problem from the hook contract itself.
Which hook stops Claude from reading sensitive .env files, and what tool names does it match?
A PreToolUse hook, matched against the file-reading tools: Read, and Grep and Glob if you want to stop the file being searched as well as opened. PreToolUse fires before every tool call, so it is the one that reliably prevents the read. PermissionRequest also fires before the call, but only when a permission dialog would appear. PostToolUse cannot help here because the file has already been read by the time it fires. Match on the path inside tool_input rather than on tool_name alone, then return a permissionDecision of deny with a permissionDecisionReason.
What are the permissionDecision values for a PreToolUse hook?
allow, deny, ask, and defer. allow runs the tool, deny blocks it, ask escalates to the user, and defer ends the run so the tool call can be resumed later. Claude Code honors defer only in non-interactive -p mode and ignores it in an interactive session. They go inside hookSpecificOutput alongside permissionDecisionReason, and optionally updatedInput to rewrite the tool arguments and additionalContext to inject text the model sees.
How many hook events does Claude Code have?
Thirty-three, as of 29 September 2026. Three were added since our 26 July check: PreModelSwitch, PostModelSwitch and DirectoryAdded. Most events are for logging or context injection. The ones that can block are PreToolUse, PermissionRequest, UserPromptSubmit, UserPromptExpansion, PostToolBatch, SubagentStop, Stop, PreCompact, PreModelSwitch, ConfigChange, TaskCreated, TaskCompleted, TeammateIdle, WorktreeCreate, WorktreeRemove, Elicitation and ElicitationResult. The rest observe and cannot change the outcome.
Does Claude Code have onStart and onFinish hooks?
No. Claude Code does not use onStart or onFinish as event names. A session starting is SessionStart and a session ending is SessionEnd; the end of a single turn is Stop, and SubagentStop for a subagent. If you are porting from an SDK that uses onStart and onFinish, map onStart to SessionStart and onFinish to Stop for per-turn work or SessionEnd for per-session work.
Can a hook pause Claude Code until a human answers?
Yes, on PreToolUse, which fires before the tool call executes and can hold it open. PermissionRequest also fires pre-execution, but only once a permission decision is already being requested, so PreToolUse is the one to gate on. A PreToolUse hook that blocks holds the call open, so it is the right place to ask a person rather than just notify them. Pushary uses this: it attaches to PreToolUse, sends the pending decision to your phone, and returns allow or deny from your answer, writing every decision to an audit trail. A Stop hook cannot do this because the turn has already finished by the time it runs.
How do you use PreToolUse hooks in the Claude Agent SDK?
Pass a function in the hooks option of query(), under PreToolUse, with an optional matcher such as Bash or Read. The function receives the same input a Claude Code command hook reads on stdin, including tool_name and tool_input, and returns the same hookSpecificOutput, so a permissionDecision of deny blocks the call. The TypeScript SDK accepts all 33 event names; the Python SDK accepts 10 of them. To ask a person rather than apply a fixed rule, use the SDK's canUseTool callback, which runs when a tool call needs permission.
What is hookSpecificOutput in a Claude Code hook?
It is the object a hook prints on stdout to return a structured decision instead of relying on an exit code. It always carries hookEventName, and the rest of the fields depend on the event: permissionDecision and permissionDecisionReason on PreToolUse, a decision object with behavior on PermissionRequest, updatedToolOutput on PostToolUse, additionalContext on most of the observing events. Exit 0 alongside it, because a non-zero exit is interpreted before the JSON is.