A close read of PreToolUse and PostToolUse — one controls a call before execution; the other can annotate or transform a successful result before Claude continues.
PreToolUse runs before execution. A successful call reaches PostToolUse; a failed call reaches PostToolUseFailure.
Both hooks receive common session and tool-call metadata. PostToolUse additionally receives the successful tool_response.
| Scope | Fields and meaning |
|---|---|
| shared | tool_name, tool_input and tool_use_id identify and correlate the call |
| shared | session_id, transcript_path, cwd and hook_event_name locate the event |
| shared | permission_mode: default, auto, acceptEdits, dontAsk, bypassPermissions or plan |
| conditional | agent_id / agent_type identify subagent calls; agent type may also identify a configured session agent |
| Post only | tool_response contains the successful tool’s structured output |
A PreToolUse hook answers one question — should this call happen, and with what input? — via hookSpecificOutput.permissionDecision.
The tool succeeded and already ran — PostToolUse cannot undo it. Its principal role is to shape the result and context Claude receives next.
A hook can answer purely through its exit code — but the same code means something different depending on which side of the call it runs on.
| Exit code | PreToolUse | PostToolUse |
|---|---|---|
| 0 | Success — JSON on stdout is read, no block | Success — JSON on stdout is read |
| 2 | Blocks the call — stderr becomes the reason Claude sees | Cannot block — the tool already ran; stderr is sent to Claude as feedback |
| other | Non‑blocking error — call proceeds | Non‑blocking error — nothing to stop |
The matcher string decides which tool calls reach the hook at all — and it's read two different ways depending on what's in it.
Only letters, digits, _ -, spaces, commas, pipes → matched literally.
"Bash" · "Edit|Write" · "Edit, Write"
A paren, bracket, *, ., ^ or $ → the whole string becomes a regex.
"mcp__memory__.*" · "^mcp__" · ".*"
Inside the top-level hooks object, three conceptual levels connect an event to the handlers that run.
The Agent SDK can replace an external hook command with an in-process callback. It can also load settings-based hooks when configured to do so.
// wrapper reads tool_input.file_path { "hooks": { "PostToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-file.sh", "args": [] }] }] } }
from claude_agent_sdk import ( ClaudeAgentOptions, HookMatcher ) options = ClaudeAgentOptions( hooks={ "PreToolUse": [ HookMatcher( matcher="Bash", hooks=[my_guard] # your fn ) ] } )
Every hook matching an event fires in parallel, not in the order they're declared.
What hooks can enforce — and what they can't get around.
A hook script has your filesystem access and credentials — it validates its own input, nothing sandboxes it for you.
A PreToolUse deny blocks the call in every permission mode. allow can't override a settings‑level deny rule.
By the time it runs, side effects already happened — it can only shape what Claude reads about them.
These tool hooks default to 600s. A timed-out PreToolUse callback prevents that call and returns a timeout result to Claude.
Most real hooks are one of these, wearing different clothes.
Scan tool_input for a Bash command touching .env or a key pattern → deny with a reason.
Matcher Edit|Write runs a wrapper that reads tool_input.file_path, then formats the touched file.
One hook sets updatedToolOutput so Claude receives one normalized result shape across MCP and built‑in tools.
Log attempts in PreToolUse and outcomes in PostToolUse / PostToolUseFailure. Store records outside conversation context.
Before the call, you guard the gate. After success, you shape the result. Failure has its own recovery hook.