Skip to content
Mastering Claude

Home / Extending: skills, MCP, subagents, hooks, plugins

Extending: skills, MCP, subagents, hooks, plugins11 minApplication

Hook events, from trigger to message

PreToolUse runs before the tool and can prevent the call; PostToolUse runs afterwards and can only react, including by replacing the tool's result. A hook communicates its decision through a JSON object with precise fields and through its exit code, never through a sentence written to standard output.

A PreToolUse hook runs before the tool activates: its decision can prevent the call from happening. A PostToolUse hook runs afterwards, once the tool has already launched: its decision can no longer prevent anything, only react, including by replacing the tool's result with the updatedToolOutput field. This difference in timing determines what a hook can do at each event.

About thirty events, from startup to end of session

The official documentation lists around thirty lifecycle events, from SessionStart and UserPromptSubmit through to PreCompact, WorktreeCreate, or SessionEnd. Each covers a precise moment: a file changing, a model switch, a subagent starting or ending, a prompt being expanded. It extends the previous lesson: an automatism is always declared on one of these events, never on a general intention.

The timeout depends on the hook type, not just the event

A command, http, or mcp_tool hook has six hundred seconds by default, three events lower it to thirty seconds and MessageDisplay to ten. A prompt hook keeps thirty seconds, an agent hook sixty, whatever the event.

What actually reaches the model: JSON fields, not free text

A hook does not communicate its decision by writing a sentence to standard output. It returns a JSON object whose hookSpecificOutput.permissionDecision field carries the value allow or deny, accompanied by a permissionDecisionReason that explains the choice, and the hookEventName field repeats the exact name of the triggering event.

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Commande destructive detectee dans le motif teste"
  }
}

The script's exit code also matters, independently of the JSON: an exit code of 2 blocks the action even if the JSON body said allow. This rule has named exceptions, PermissionRequest, StopFailure outside a terminal sequence, PermissionDenied, and above all PostToolUse and PostToolUseFailure, where exit code 2 becomes non blocking and merely gets displayed to Claude via standard error: logical, since the tool has already finished running by the time this hook fires.

Deferring rather than deciding

The permissionDecision field accepts four values: allow skips the permission prompt, with documented exceptions, deny prevents the call, ask prompts the user to confirm, and defer ends the turn so the tool can be resumed later, only in non-interactive mode with -p. To hand the decision back to the usual permission flow rather than forcing it, a hook writes none of these values: it exits with code 0 without reporting a decision, and the call continues along its normal approval path.

Testing a hook through its standard input

A hook error lets the action through and is announced only by a notice in the transcript, easy to miss: a badly formed test returns a silence that looks like "the hook does not react". The hook reads a JSON on its standard input, so you can play it by hand, with a case it must refuse and a control it must let through. The following script and its two calls create their own data:

cat > garde.sh << 'EOF'
#!/bin/sh
ENTREE=$(cat)
case "$ENTREE" in
  *"npm publish"*) echo "Refused: run npm publish --dry-run first." >&2; exit 2 ;;
esac
exit 0
EOF
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"npm publish"}}' | sh garde.sh; echo "code=$?"
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' | sh garde.sh; echo "code=$?"
Refused: run npm publish --dry-run first.
code=2
code=0

The first call proves the guard refuses, the second that its silence means "allowed" and not "broken". Write the JSON with printf '%s' between single quotes: a shell that collapses a backslash breaks the JSON with no visible error.

Does a broken guard let things through?

Since October 2026, a PreToolUse or PermissionRequest hook whose matching fails, or whose input cannot be serialized, blocks the call instead of being skipped. On Windows, a guard with no declared shell also blocks when Git for Windows is missing. A hook run by PowerShell that exited with code 2 did not block before the October fix: reread guards written earlier. For a security guard to block in every failure case, declare "onFailure": "block".

Figure 1

Timeout by hook type and event

Default timeoutHook type concernedDefault timeoutParticularity
Ordinary command, http, or mcp_tool hookcommand, http, mcp_tool600 secondsApplies to most lifecycle events
UserPromptSubmit, PreModelSwitch, PostModelSwitchcommand, http, mcp_tool30 secondsThese three events lower the timeout normally set at 600 seconds
MessageDisplaycommand, http, mcp_tool10 secondsThe shortest timeout of all documented events
SessionEndAll types combined1.5 seconds for the whole set of hooks on the eventExtendable up to 60 seconds if a hook explicitly declares a longer timeout
Hook of type promptprompt30 secondsTimeout fixed by the type, independent of the triggering event
Hook of type agentagent60 secondsTimeout fixed by the type, independent of the triggering event
The table crosses hook type with its default timeout, and isolates the events that lower this timeout or that follow a separate rule, such as SessionEnd.
Figure 2

From the event to the decision passed to the model

01
Event triggered
A tool is about to run, or has just done so, depending on the registered event.
02
Hook executed
The harness launches the command, HTTP call, MCP tool, prompt, or agent declared for this event.
03
Decision returned in JSON
hookSpecificOutput.permissionDecision carries allow or deny, accompanied by permissionDecisionReason, and hookEventName repeats the event's name.
04
Context added or action blocked
additionalContext enriches what the model sees, or the call is cancelled before happening if the event is PreToolUse.
05
Exit code 2 as a safety net
It blocks the action even if the JSON says allow, except for events that do not honour it, such as PostToolUse where it is only displayed to Claude via stderr.
The sequence places the moment when PreToolUse can still prevent the call, and the one where PostToolUse can only note what has just happened.
Calibrate it yourself

A developer configures a PostToolUse hook that returns an exit code of 2 after every call to the Bash tool. He then runs a Bash command from Claude Code and observes that the command runs to completion.

Write in one sentence what this situation establishes, and in one sentence what it does not establish.

What to remember
  • PreToolUse can prevent a tool call from happening, PostToolUse can prevent nothing but can replace the result once the tool has launched.
  • A hook is tested by hand by sending it a JSON on its standard input, with one case to refuse and a control to let through.
  • A hook's decision travels through the hookSpecificOutput.permissionDecision field, with the values allow, deny, ask or defer, never through a sentence on standard output.
  • An exit code of 2 blocks the action even when the JSON asserts allow, except on named events where it becomes a simple message displayed to Claude.
  • To defer to the usual permission flow, a hook exits with code 0 without reporting a decision, and a security guard declares onFailure: "block" to block on failure too.
Do this now

Write a minimal PreToolUse hook that exits with code 0 without writing anything to its standard output, declare it on an innocuous event, and confirm that the call continues along its normal approval path rather than being forced.

Check the source

Every datable claim in this lesson links here to the public text behind it. A source that does not open proves nothing.