Claude Code / a working model

FIELD GUIDE 01 / CLAUDE CODE HOOKS

Make the rules
run.

Intercept a tool. Check a result. Keep the work moving.
Code that runs at the right point in an agent’s lifecycle.

33 events · 5 handler types · One practical grep guard

Start with the sample

Official documentation checked 16 September 2026. Newer events and fields are version-dependent; check claude --version and /hooks in your installation. Examples are local teaching material, not a running Claude Code session.

01 / THE WORKING MODEL

When. Which. What.

Every hook has an event that decides when it runs, a matcher that selects relevant occurrences, and a handler that does the work. Claude Code invokes it; the model does not have to remember to.

EVENTPreToolUseBefore execution
MATCHERBashOnly shell tool calls
HANDLERYour scriptInspect, then decide
Four different kinds of control
MechanismJob
CLAUDE.md / skillsGuide what Claude tries to do.
PermissionsAllow, ask, or deny matching tool calls.
HooksRun custom logic at defined lifecycle points.
Sandbox / OS controlsEnforce filesystem, network, and process boundaries.

A hook can deny an already-allowed command. For pipe-only grep, keep Bash(grep *) allowed and reject direct grep in a synchronous PreToolUse handler. A hook returning no decision leaves the rest of the permission checks in place.

Can a hook replace my entire permission configuration?

You can centralize most custom policy, but the built-in permission system remains. PreToolUse can deny, ask, or allow calls; a hook’s allow does not override matching deny or ask rules.

Clearing the permissions object does not create a default-deny policy. Built-in read-only approvals and the selected mode still apply. A missing handler, a crash without a blocking decision, or a timeout normally falls back to those permissions. If everything is broadly allowed, that failure permits execution.

Keep permissions and sandboxing as the fallback. A Bash hook sees the submitted command, not every subprocess it launches. EndConversation skips tool hooks. This is tool interception, not a replacement for OS-level enforcement.

What can permissions do that a pre-tool hook cannot?
Native permission capabilityPreToolUse limitation
Hide a tool entirelyA bare deny such as "Bash" removes the tool from Claude’s available tools. A hook can reject an attempted call but cannot remove the tool definition. EndConversation is a special exception to removal while other tools remain.
Apply without a handler processA loaded deny or ask rule does not depend on your Python installation, hook startup, or timeout. Normal permissions are the fallback if a command hook renders no decision.
Preserve higher-priority policyA hook’s allow decision cannot override matching deny or ask rules, including managed restrictions.
Handle separate network approvalsThe sandbox network approval path is not a PreToolUse or PermissionRequest event. Configure sandbox network policy separately; a Bash hook does not observe each subprocess connection.

Built-in parsing is also useful, not exclusive. Native Bash matching splits compound commands and recognizes supported wrappers. A hook can implement equivalent checks, but you own its parser and gaps. Neither command-text matching nor this sample guards every possible subprocess; use OS/sandbox controls for that boundary.

02 / FIVE HANDLER TYPES

Choose the smallest tool
that can make the decision.

Handler types and useful applications
TypeWhat runsUse it for
commandYour script or executable; event JSON arrives on stdin.Precise rules, formatters, checks, notifications.
httpAn HTTP POST with the event JSON; response JSON supplies the decision.A central approval or audit service.
mcp_toolA tool on an already-connected MCP server.An existing integration or policy tool.
promptOne model evaluation, using a fast model by default.Judgment based on the supplied event data.
agentA verifier agent with tools such as Read, Grep, and Glob.Investigating files before deciding. Experimental.

A command hook’s logic can be deterministic. Prompt and agent hooks add model judgment, latency, and cost. For an exact command-shape rule, use a script.

Which events support which handlers?

All five: PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, PermissionRequest, PermissionDenied, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, TaskCreated, TaskCompleted, and TeammateIdle.

Command, HTTP, and MCP only: the other lifecycle events, except SessionStart and Setup.

SessionStart and Setup: their schema accepts command and MCP handlers, but MCP servers are unavailable at launch. SessionStart MCP hooks can run later after clear/compaction; Setup MCP hooks are always skipped. Use command handlers for initialization.

Supporting a handler type does not imply that every returned decision has an effect. Prompt/agent outputs on PermissionDenied are discarded; use a command handler for retry. Prompt ok: false does not deny a PermissionRequest; use its explicit decision object.

Reference / event catalog

Choose when your handler runs

Events decide when handlers run. Choose the event first, then narrow it with a matcher. Availability and individual fields are version-sensitive: check the linked current reference against your installed Claude Code version.

33 events. Open any event for its trigger, matcher, useful control, and limits. In the entries below, event-specific JSON fields belong in hookSpecificOutput alongside the matching hookEventName; fields explicitly called “top-level” belong outside that object.

SessionStart Load context when a session starts or resumes. Context and setup

Trigger and matcher: session initialization; match startup, resume, clear, compact, or fork.

Control: plain stdout or additionalContext supplies context. initialUserMessage creates a first turn in -p mode; sessionTitle, watchPaths, and reloadSkills configure the session. Command hooks can append environment exports to CLAUDE_ENV_FILE.

Limit: cannot block startup. Only command and MCP-tool handlers are supported; MCP availability still matters. The first response waits for startup hooks even when the UI opens immediately. sessionTitle is ignored for clear and compact.

SessionStart reference

Setup Run explicitly requested initialization or maintenance. Setup only

Trigger and matcher: --init-only, or --init/--maintenance with -p. Match init or maintenance.

Control: perform preparation as a command-hook side effect; exports written to CLAUDE_ENV_FILE persist into subsequent Bash commands. --init-only runs Setup and startup SessionStart hooks, then exits.

Limit: does not run on ordinary startup, cannot block, and discards JSON output on every exit code. Only command hooks run: mcp_tool is always skipped because Setup runs before MCP servers are ready.

Setup reference

InstructionsLoaded Observe instruction files entering context. Observe only

Trigger and matcher: a CLAUDE.md or .claude/rules/*.md file loads, including lazy loads. Match load_reason: session_start, nested_traversal, path_glob_match, include, or compact.

Control: audit file_path, memory_type, and applicable trigger, parent, or glob fields through your handler's own logging.

Limit: asynchronous observability only. Cannot block or modify instruction loading; JSON output such as systemMessage and continue is discarded.

InstructionsLoaded reference

DirectoryAdded Prepare a working directory added mid-session. After-add setup

Trigger and matcher: after /add-dir or the SDK's register_repo_root. Match slash_command or register_repo_root; read the absolute directory from input.

Control: run setup in the background after sandbox and permission state refresh. For slash_command, top-level systemMessage reaches Claude on the next turn; for register_repo_root, it goes only to the debug log.

Limit: cannot block an already-completed add; continue is discarded. Startup --add-dir, Workspace-tab additions, and already-covered directories do not trigger it. Hook commands run unsandboxed.

DirectoryAdded reference

SessionEnd Clean up when a session terminates. Cleanup only

Trigger and matcher: session exit, clear, or interactive session switch. Match the reason: clear, resume, logout, prompt_input_exit, or other.

Control: perform cleanup, save state, or log statistics as handler side effects.

Limit: cannot block termination and discards JSON output. Default timeout is 1.5 seconds. Settings-file per-hook timeouts can raise the overall budget up to 60 seconds; CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS explicitly overrides the budget. Keep cleanup bounded.

SessionEnd reference

PreToolUse Inspect or gate a tool call before execution. Block or rewrite

Trigger and matcher: after parameters are created, before execution and permission processing. Match the tool name, including MCP tool names. Already-allowed calls still reach this hook; EndConversation bypasses tool hooks.

Control: return permissionDecision as allow, deny, ask, or defer, with permissionDecisionReason. updatedInput replaces the entire input object; additionalContext adds context. Exit 2 also denies.

Limit: precedence is deny > defer > ask > allow: an allow cannot undo a denial. Deny and ask permission rules still apply. defer works only for a single tool call in -p mode. A timed-out command hook normally lets the tool continue.

PreToolUse reference

PermissionRequest Decide a tool permission request. Allow or deny

Trigger and matcher: when permission is needed, including a call that would otherwise be auto-denied because it cannot prompt. Match the tool name.

Control: return hookSpecificOutput.decision with behavior: "allow" or "deny". Allow can include updatedInput and updatedPermissions; deny can include message and interrupt: true.

Limit: exit 2 alone does not deny; without the decision object the permission flow is unchanged. Allow does not override matching deny rules. Not an every-tool gate, and does not fire for EndConversation or sandbox network-request prompts.

PermissionRequest reference

PostToolUse Inspect or replace a successful tool result. Feedback or output rewrite

Trigger and matcher: immediately after a tool succeeds. Match the tool name; input includes tool_input and structured tool_response. EndConversation is excluded.

Control: additionalContext adds feedback; top-level decision: "block" adds its reason beside the result. updatedToolOutput replaces what Claude receives for built-in or MCP tools. classifierContext supplies a short note to the auto-mode classifier, not Claude.

Limit: cannot undo files, commands, or network effects. Built-in replacements must match the tool's output schema or are ignored. Rewrites do not redact telemetry: original output was already captured. A block decision alone does not hide the original output.

PostToolUse reference

PostToolUseFailure Provide feedback after an executing tool fails. Failure context

Trigger and matcher: a tool that started executing throws an error or an MCP tool returns an error result. Match the tool name; inspect error, optional is_interrupt, and duration_ms.

Control: return additionalContext beside the error to guide recovery. Exit 2 shows stderr to Claude but cannot reverse the failure.

Limit: does not cover unknown tools, input validation rejections, or permission denials before execution. Cancelling a running tool does not fire this event. Error text varies by tool; do not treat the entire message as a stable machine-readable format.

PostToolUseFailure reference

PostToolBatch React once after a batch of tool calls resolves. Context or stop loop

Trigger and matcher: all calls in a batch have resolved, before the next model request. No matcher; inspect the tool_calls array.

Control: return additionalContext once for the whole batch. Top-level decision: "block", continue: false, or exit 2 stops the agentic loop before the next model call. Supply top-level reason/stopReason, or stderr for exit 2.

Limit: calls have already run. Here tool_response is serialized tool-result content, not PostToolUse's structured output object. The blocking warning remains in the conversation for a later continuation.

PostToolBatch reference

UserPromptSubmit Validate a prompt before Claude processes it. Block or add context

Trigger and matcher: a user submits a prompt. No matcher; inspect prompt inside the handler.

Control: plain stdout or additionalContext adds context. Top-level decision: "block" with reason, or exit 2 with stderr, blocks processing and erases the prompt from context. sessionTitle can name the session.

Limit: context is a system reminder, not a visible transcript entry. Command, HTTP, and MCP-tool handlers default to 30 seconds; a timeout discards their context but lets the prompt proceed. Agent SDK callback timeouts instead block the prompt.

UserPromptSubmit reference

UserPromptExpansion Gate a user-typed command's prompt expansion. Block or add context

Trigger and matcher: a directly typed skill, custom command, or MCP prompt expands before reaching Claude. Match command_name; input includes arguments, source, original prompt, and expansion_type.

Control: top-level decision: "block" with reason, or exit 2, blocks expansion. additionalContext adds context alongside the expanded prompt.

Limit: this covers the direct user-command path, not every tool call. A PreToolUse matcher on Skill covers Claude calling that tool, but not a user typing /skillname.

UserPromptExpansion reference

MessageDisplay Transform assistant text as it is displayed. Visual replacement only

Trigger and matcher: each completed-line batch while assistant text streams; no matcher. In -p and Agent SDK runs, fires once with the full message. Input uses delta, message_id, index, and final.

Control: return displayContent to replace that delta on screen; omit it to keep the original.

Limit: visual only. The transcript, Claude's context, and verbose mode retain original text; tools and user messages are unchanged. Failures or timeouts display the original text, so this is not a secure redaction boundary. Cannot block; systemMessage and continue are discarded.

MessageDisplay reference

Notification Forward a notification through your own handler. Notify only

Trigger and matcher: Claude Code emits a notification, even if desktop notifications are disabled. Match notification_type, such as permission_prompt, idle_prompt, auth_success, or an elicitation/agent/quota notification.

Control: use message and optional title for handler side effects. Top-level terminalSequence can emit a terminal notification.

Limit: cannot block or modify the notification; systemMessage and continue are discarded. Permission notifications are delayed about six seconds, and terminal typing can defer them. Use PermissionRequest for an immediate tool-permission signal.

Notification reference

PermissionDenied Observe an auto-mode denial or suggest retrying. Auto-mode retry hint

Trigger and matcher: auto mode denies a tool call, including denials without a classifier verdict. Match the tool name; inspect reason.

Control: return retry: true to tell Claude it may retry the denied call. Otherwise the original rejection stands.

Limit: does not reverse the denial or automatically execute a retry. retry is ignored for no-verdict denials. This event never covers manual denial, a PreToolUse block, or a matching deny rule; exit code and stderr cannot change the completed denial.

PermissionDenied reference

SubagentStart Give a starting or resumed subagent context. Subagent context

Trigger and matcher: Agent spawns or resumes a subagent, or an in-process teammate handles a new message. Match agent_type: built-in name, custom frontmatter name, or plugin-scoped name.

Control: return additionalContext for the subagent before its prompt. Use agent_id to correlate runs.

Limit: cannot block creation. Repeated runs do not reinject context while the earlier copy remains. Plugin names contain a colon and enter the regex matcher path; use an anchored pattern such as ^my-plugin:reviewer$ for an exact match.

SubagentStart reference

SubagentStop Check whether a subagent should finish responding. Keep subagent working

Trigger and matcher: a subagent finishes responding. Match agent_type, as for SubagentStart.

Control: top-level decision: "block" with reason, or exit 2 with stderr, gives the subagent its next instruction. additionalContext continues it with non-error feedback. Check stop_hook_active to avoid loops.

Limit: feedback goes to the subagent, not its parent. agent_transcript_path is the subagent transcript; transcript_path is the parent. With SubagentHandback, last_assistant_message is closing text, not necessarily the delivered report; inspect that tool's message input for the report.

SubagentStop reference

TaskCreated Enforce criteria for a newly created task. Reject task creation

Trigger and matcher: a task is being created through TaskCreate. No matcher; inspect task_id, task_subject, and optional description and teammate fields.

Control: exit 2 with stderr, or top-level decision: "block" with reason, deletes the new task and returns the message as the tool error.

Limit: does not fire in sessions without Task tools. Top-level continue: false is ignored: rejection rolls back task creation, but Claude keeps working.

TaskCreated reference

TaskCompleted Require evidence before a task is marked complete. Gate task completion

Trigger and matcher: TaskUpdate marks a task complete, or a teammate finishes its turn with in-progress tasks. No matcher; inspect the task's ID, subject, and available description.

Control: exit 2 prevents completion and feeds stderr back to the model. Top-level continue: false with stopReason stops a teammate entirely when its end-of-turn triggered the event.

Limit: when TaskUpdate triggered the event, continue: false is ignored. Use exit 2 for a completion gate rather than assuming Stop's JSON decision schema applies here.

TaskCompleted reference

TeammateIdle Check a teammate before it becomes idle. Continue or stop teammate

Trigger and matcher: an agent-team teammate is about to go idle after its turn. No matcher; input identifies teammate_name.

Control: exit 2 sends stderr to the teammate as feedback and keeps it working. Top-level continue: false with stopReason instead stops the teammate entirely and shows the reason to the user.

Limit: this is a teammate lifecycle event, not the main agent's Stop event. “Block idle” and “stop teammate” have opposite effects; select the documented control deliberately.

TeammateIdle reference

Stop Check whether the main agent should finish its turn. Continue the conversation

Trigger and matcher: the main agent finishes responding. No matcher. Read last_assistant_message directly; the transcript file may lag. Background-task and session-cron arrays can distinguish done from waiting.

Control: top-level decision: "block" requires a reason telling Claude to continue; exit 2 uses stderr. additionalContext also continues the conversation, but labels the message as feedback rather than an error.

Limit: no Stop event for a user interrupt; API errors use StopFailure. Guard stop_hook_active and use a reachable condition to prevent loops. Claude Code ends the turn after eight consecutive hook continuations.

Stop reference

StopFailure Observe a turn ending because of an API error. Error notification only

Trigger and matcher: an API error ends the turn instead of normal Stop. Match error, such as rate_limit, authentication_failed, server_error, or cloud_credential_error.

Control: log or alert through handler side effects; top-level terminalSequence can emit a terminal notification.

Limit: output and exit code are otherwise ignored; no decision can keep this failed turn running. Optional last_assistant_message contains rendered API error text, unlike Stop's conversational response. Error types are version-sensitive.

StopFailure reference

PreCompact Inspect or block an impending context compaction. Can block compaction

Trigger and matcher: before compaction. Match manual for /compact or auto for automatic compaction; input includes trigger and nullable custom_instructions.

Control: exit 2 or top-level decision: "block" blocks compaction. For manual compaction, exit-2 stderr is shown to the user.

Limit: blocking proactive auto-compaction continues without compacting. Blocking recovery compaction after an API context-limit error exposes that error and fails the request. Top-level systemMessage and continue are discarded.

PreCompact reference

PostCompact Observe the generated compaction summary. Observe only

Trigger and matcher: after compaction completes. Match manual or auto, as for PreCompact.

Control: read compact_summary and trigger to log the new state or update an external record as a handler side effect.

Limit: no decision control; cannot change the result or undo compaction. Top-level systemMessage and continue are discarded. Use PreCompact when the requirement is to stop compaction before it happens.

PostCompact reference

ConfigChange Audit or gate configuration-file changes. Block non-policy reloads

Trigger and matcher: settings, managed-policy files, or skill files change during the session. Match user_settings, project_settings, local_settings, policy_settings, or skills.

Control: exit 2 or top-level decision: "block" prevents new settings being applied to the running session.

Limit: policy_settings cannot be blocked. Server-managed refreshes and some OS-managed policy changes do not fire this event. Blocking prevents application, not the disk edit; reason is never shown, and systemMessage/continue are discarded. Inspect the debug log for a blocked reload.

ConfigChange reference

PreModelSwitch Gate a user- or client-requested model switch. Block or ask

Trigger and matcher: before a requested model change. Match the target's canonical model name, ignoring [1m]. Unknown canonical names run every hook, so check input to_model too.

Control: exit 2 or top-level decision: "block" cancels. permissionDecision accepts allow, deny, or ask, with permissionDecisionReason; precedence is deny, ask, allow. Top-level systemMessage can show a cost estimate.

Limit: timeout blocks the switch, unlike a timed-out PreToolUse command. Default: 30 seconds. Only interactive /model can show “ask”; other surfaces treat it as refusal. Automatic switches do not fire this event. No defer, input rewrite, or additional context. Requires v2.1.251+.

PreModelSwitch reference

PostModelSwitch Add guidance after the session's model changes. Model-specific context

Trigger and matcher: requested changes, automatic session-model fallback, plan-mode transitions, and restored models on resume. Match the target's canonical model name, using the PreModelSwitch rules.

Control: plain stdout on exit 0 or additionalContext reaches Claude with the next request. Input includes from_model, to_model, and source.

Limit: cannot block a completed switch. After five seconds of waiting following the next prompt, late output moves to a later request. Only the last target's output survives multiple intervening changes. A one-turn fallback-chain substitution does not trigger it. Requires v2.1.251+.

PostModelSwitch reference

CwdChanged Refresh the environment after a directory change. Environment and watches

Trigger and matcher: a shell command in the main conversation changes the working directory. No matcher; read old_cwd and new_cwd.

Control: write exports to CLAUDE_ENV_FILE; return absolute watchPaths to replace the dynamic FileChanged watch list. An empty array clears that list. Top-level systemMessage shows a brief interactive terminal notification.

Limit: cannot block the directory change; continue is discarded. These environment exports are cleared on the next CwdChanged event. Matcher-configured file watches remain even when the dynamic list is replaced; the notification does not reach the SDK message stream.

CwdChanged reference

FileChanged React when a watched file changes on disk. Filesystem observation

Trigger and matcher: a filesystem watcher sees a change, add, or unlink, whoever wrote the file. The matcher splits on | into literal filenames to watch, not globs or regex watch patterns. It also filters handlers against the changed basename using normal matcher rules.

Control: read file_path/event; return absolute watchPaths to replace dynamic watches, or write environment exports to CLAUDE_ENV_FILE. Top-level systemMessage shows an interactive terminal notification.

Limit: cannot prevent the change. Seed the watcher with named files or SessionStart/CwdChanged watchPaths; omitted matcher alone watches nothing. "*" registers a literal file named *. Guard any rewriting handler against triggering itself forever.

FileChanged reference

WorktreeCreate Replace default creation of an isolated working copy. Custom creation contract

Trigger and matcher: --worktree, a subagent with isolation: "worktree", or an isolated background session needs a worktree. No matcher; input includes its name.

Control: create the directory yourself. Command hooks print its path as the last non-empty stdout line; HTTP hooks return worktreePath. Success plus a valid, enterable directory is required.

Limit: replaces default git behavior entirely, including .worktreeinclude copying. Failure or missing path fails creation; standard allow/block JSON is not the contract. Return a normalized path without symlink traversal below the repository root. Pair non-git creation with WorktreeRemove.

WorktreeCreate reference

WorktreeRemove Clean up a custom isolated working copy. Cleanup result controls removal

Trigger and matcher: removal at worktree-session exit, an isolated subagent finishing, or deletion of a hook-created background-session worktree. No matcher; input supplies absolute worktree_path.

Control: perform the cleanup. A nonzero exit while the directory still exists makes removal fail; for background-session deletion, the session stays too.

Limit: JSON output, including systemMessage and continue, is discarded. Without a paired remove hook, a custom non-git worktree is left on disk. Validate the provided path carefully before any destructive cleanup; this is not a generic Stop-style decision hook.

WorktreeRemove reference

Elicitation Handle an MCP server's request for user input. Respond or decline

Trigger and matcher: an MCP server requests input during a tool call. Match the MCP server name; input describes form or URL mode, with relevant schema, message, or URL fields.

Control: return action as accept, decline, or cancel to respond without the dialog. content carries form values when accepting. Exit 2 denies the elicitation.

Limit: exit-2 stderr is not shown. systemMessage and continue are discarded. Accepting a form and completing a URL-mode browser flow are different operations; form content is meaningful only for an accepted form response.

Elicitation reference

ElicitationResult Inspect an elicitation response before the server gets it. Rewrite or decline response

Trigger and matcher: after the user responds to an MCP elicitation, before that response is sent back. Match the MCP server name; input includes action and optional mode, elicitation_id, and content.

Control: return action as accept, decline, or cancel to override the response, with content to override accepted form values. Exit 2 changes the effective action to decline.

Limit: exit-2 stderr is not shown. Top-level systemMessage and continue are discarded; accepted form content is not a general replacement for a URL authentication flow.

ElicitationResult reference

04 / CONFIGURATION

One event. One matcher.
One handler to begin.

Add a hooks object to your settings. Merge entries into the existing configuration; do not replace unrelated settings.

Where hooks live
LocationScope
~/.claude/settings.jsonYour projects globally.
.claude/settings.jsonShared project configuration.
.claude/settings.local.jsonLocal project configuration.
Managed settingsAdministrator-controlled policy.
Plugins / skill / subagent frontmatterPackaged or component-specific behavior.

Hooks merge across settings levels. Matching handlers run in parallel, so dependent checks belong in one script. Skill hooks remain for the rest of the session after invocation; subagent hooks apply while that subagent runs. once: true is honored only in skill frontmatter.

Minimal structure · replace the script path
{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "python3",
        "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check.py"],
        "timeout": 5
      }]
    }]
  }
}

Matcher = which event

Bash selects the Bash tool. Edit|Write selects either file tool. ^mcp__github__ is a regex for matching MCP tool names. Other events match sources, agent types, or model names; some have no matcher.

On supported tool events, "if": "Bash(git *)" narrows when a handler runs using permission-rule syntax. It is an invocation filter, not the validation logic itself.

Handler = what executes

With args, the executable is spawned directly. Each argument stays intact; no shell expands pipes or &&. Without args, the command is interpreted by a shell.

Use ${CLAUDE_PROJECT_DIR} for project scripts, or ${CLAUDE_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_DATA} for plugins. In a worktree, the project placeholder stays at the original root; input cwd follows the current directory.

Runtime fields and timeouts

timeout is in seconds. Defaults are generally 600 for command/HTTP/MCP, 30 for prompt, and 60 for agent handlers. Several events have shorter defaults: UserPromptSubmit and model switches use 30; MessageDisplay uses 10; SessionEnd shares a 1.5-second budget that can be increased within documented limits.

statusMessage changes the spinner. async and asyncRewake are command-only. Async hooks cannot gate the action. Hooks inherit the process environment; do not assume a login shell or an interactive terminal.

05 / DECISIONS & DATA

No decision is not approval.

A command hook reads JSON on stdin. It returns an exit status, or structured JSON on stdout. Never run the proposed command just to inspect whether it is allowed.

Example input · PreToolUse
{
  "hook_event_name": "PreToolUse",
  "session_id": "example-session",
  "cwd": "/home/me/project",
  "permission_mode": "default",
  "tool_name": "Bash",
  "tool_input": { "command": "make test" },
  "tool_use_id": "example-call"
}

Normal permissions still apply.

Exit 0 with no output makes no decision. Every command in the pipeline must still pass the normal permission flow.

# No stdout. Exit successfully.
sys.exit(0)

Illustrates documented outcomes, not a live permission evaluator. A timeout here means a command/HTTP/MCP PreToolUse hook, not an Agent SDK callback.

Reject before execution

For PreToolUse, exit 2 with a reason on stderr, or return the JSON below on exit 0. Exit 1 alone is usually a non-blocking error.

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Use the Grep tool."
  }
}

Control an approval prompt

PermissionRequest runs only when permission needs a decision. Its schema differs, and exit 2 alone does not reject the request.

{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": {
      "behavior": "deny",
      "message": "Approval required."
    }
  }
}

Deny and ask rules still apply. A hook’s allow cannot override a matching deny. When pre-tool hooks disagree, precedence is deny > defer > ask > allow. For a restriction-only hook, stay silent on acceptable commands rather than granting new authority.

Beyond allow or deny
CapabilityOutput and limits
Modify tool argumentsPreToolUse hookSpecificOutput.updatedInput replaces the whole input object. Keep unchanged fields you still need; permissions evaluate the replacement.
Replace a tool resultPostToolUse updatedToolOutput inside hookSpecificOutput must match the output schema. The tool already ran; telemetry may retain the original.
Inject contextSupported events accept hookSpecificOutput.additionalContext. Prefer factual environment context; static instructions belong in CLAUDE.md.
Make work continueStop decision: "block" with a reason blocks stopping. Or return Stop additionalContext for non-error feedback. Check stop_hook_active; continuation is capped.
Stop processingcontinue: false with stopReason stops processing on events that honor it. This is the opposite of blocking a Stop event.
External approvals, permissions, and model decisions

Defer: PreToolUse permissionDecision: "defer" pauses a supported claude -p single-tool turn for a host application to collect input and resume. Interactive sessions and multi-tool batches do not support this decision.

Persist permissions: PermissionRequest can return updatedPermissions to add, replace, or remove rules; change mode; or add/remove working directories. Destinations are session memory, local, project, or user settings. Existing managed controls still apply.

Prompt/agent results: return {"ok": true} or {"ok": false, "reason": "..."}. Their interpretation depends on the event. Prompt continueOnBlock changes supported events from ending the turn to feeding back a correction. Prompt Stop checks may use impossible: true to allow ending when a condition cannot be satisfied; agent hooks do not support that field.

User messages: systemMessage normally shows a warning to the user, but some events discard or reroute it. terminalSequence supports an allowlist of notification/title/bell sequences in interactive mode. suppressOutput is accepted but has no effect.

Worked example

Allow grep only as a receiving pipeline stage

Keep the ordinary grep permission. Add a synchronous PreToolUse hook that rejects direct grep calls and grep at the start of a pipeline.

Permission rules match subcommands, not their position in a pipeline: both producer | grep pattern and producer && grep pattern file.txt contain a grep subcommand. Bash(grep *) includes bare grep, so adding Bash(grep) is redundant. Likewise, Bash(make *) includes bare make; Bash(make) adds nothing alongside it.

Deny wins over ask, which wins over allow. A broad grep deny also blocks piped grep; an allow rule cannot create an exception. Review conflicting deny rules before adopting this example. Do not remove a policy you do not control.

This is a minimal pipeline-position policy, not a stdin-only policy or a security boundary. It checks shell syntax with bashlex==0.18 without executing the proposed command. Wrappers and dynamic command names can escape this check; a hook that cannot start or times out does not block the call.

Set up in three steps

For macOS/Linux with Python 3 and venv support. These commands use a dedicated environment; no executable bit is needed on the Python file because the interpreter runs it directly.

  1. Create the virtual environment and install the pinned parser

    Terminal
    mkdir -p "$HOME/.claude/hooks"
    python3 -m venv "$HOME/.claude/hooks/venv"
    "$HOME/.claude/hooks/venv/bin/python" -m pip install 'bashlex==0.18'
  2. Save the complete Python file

    Save the code below as ~/.claude/hooks/grep-pipe-only.py, or download grep-pipe-only.py and place it at that path.

    grep-pipe-only.py
    import json
    import os
    import sys
    
    
    def deny(message):
        print(message, file=sys.stderr)
        sys.exit(2)
    
    
    try:
        import bashlex
    
        payload = json.load(sys.stdin)
        if payload.get("tool_name") != "Bash":
            sys.exit(0)
    
        class GrepPipelineGuard(bashlex.ast.nodevisitor):
            def __init__(self):
                self.piped = set()
    
            def visitpipeline(self, node, parts):
                for left, right in zip(parts, parts[1:]):
                    if left.kind == "pipe" and right.kind == "command":
                        self.piped.add(id(right))
    
            def visitcommand(self, node, parts):
                words = [part.word for part in parts if part.kind == "word"]
                if words and os.path.basename(words[0]) == "grep":
                    if id(node) not in self.piped:
                        deny("grep must be a receiving pipeline stage, not a direct command or pipeline producer.")
    
        guard = GrepPipelineGuard()
        for tree in bashlex.parse(payload["tool_input"]["command"]):
            guard.visit(tree)
    except Exception as exc:
        deny(f"Cannot check the Bash pipeline-position policy: {exc}")
    
  3. Merge the settings into your chosen scope

    Add the allow entry to permissions.allow and append this matcher group to hooks.PreToolUse in ~/.claude/settings.json for all projects, or .claude/settings.json for one project. Preserve unrelated settings and existing array entries; do not paste a second top-level hooks key. If the file is new, the complete object below is valid on its own. Download the settings example.

    settings.json — merge, do not overwrite
    {
      "permissions": {
        "allow": [
          "Bash(grep *)"
        ]
      },
      "hooks": {
        "PreToolUse": [
          {
            "matcher": "Bash",
            "hooks": [
              {
                "type": "command",
                "command": "\"$HOME/.claude/hooks/venv/bin/python\" \"$HOME/.claude/hooks/grep-pipe-only.py\"",
                "timeout": 5
              }
            ]
          }
        ]
      }
    }
    

    The hook is synchronous by default; do not add async: true. The timeout is five seconds. Open Claude Code’s read-only /hooks browser to confirm the loaded event, matcher, and command.

What this hook decides

A rejected call prints its reason to stderr and exits 2. A passing call exits 0 with no output: continue normal permission checks, not “allow this whole command.” The upstream producer and every other stage are not auto-approved by this hook.

Hook outcomes, separate from Claude Code’s final permission decision
Proposed Bash commandHook outcomeReason
grep pattern file.txtDeny · exit 2Direct grep has no incoming pipe.
producer | grep patternNo decision · exit 0Grep is a receiving stage; normal permissions still apply to the producer.
grep pattern file.txt | sortDeny · exit 2Grep is the producer, not a receiving stage.
grep 'a|b' file.txtDeny · exit 2A quoted pipe character is not a pipeline operator.
producer | grep pattern; grep other file.txtDeny · exit 2A permitted pipeline does not excuse a later direct grep.

The visitor parses the whole command and walks nested command/process substitutions too. It records only command nodes immediately following pipe nodes, then checks direct executable words whose basename is grep, including literal paths such as /usr/bin/grep.

Limitations and failure behavior — read before enabling
  • No wrapper, alias, script, or dynamic-name inspection. Forms such as command grep, bash -c 'grep pattern file.txt', a script that calls grep, or $cmd pattern are not identified as grep by this guard. Claude Code’s own permission matching can recognize wrappers that this sample does not.
  • Grouped stages are conservatively denied. producer | (grep pattern) puts a compound node after the pipe, so the nested grep is not recorded as a receiving command.
  • Files remain allowed by this position check. producer | grep pattern file.txt and producer | grep pattern < file.txt pass the hook. File operands, recursive options, pattern-file reads, and input redirection are not restricted; this is not stdin-only enforcement.
  • Parser support is limited. Bash syntax unsupported by bashlex==0.18 is rejected. Dependency-import, JSON-input, and parse exceptions inside the running script become stderr plus exit 2; they do not silently pass.
  • Startup and timeout failures remain non-blocking. A missing interpreter or script, or a five-second timeout, is outside this exception handler. A timed-out command hook renders no decision, so ordinary permission checks continue. Use protected policy configuration and OS/sandbox controls when you need a security boundary.

Verification scope: the sample logic was exercised in isolated subprocesses, not an actual Claude Code integration. That checks script decisions, not your installed client, settings loading, hook startup, or timeout behavior. No real user settings were changed for that verification.

Reference: PreToolUse, decision control, exit 2, and timeouts.

07 / USEFUL PATTERNS

Automate the repeatable part.

PRETOOLUSE

Enforce a workflow

Reject direct shell searches, protect a specific editing path, or require a review before a deployment. Match every relevant tool; an Edit-only rule does not inspect a write through Bash.

POSTTOOLUSE

Format after an edit

Match Edit|Write, read the actual file path from input, and run your formatter with an argument array. Avoid turning a path with spaces into several arguments.

NOTIFICATION

Ask for attention

Use permission_prompt for a waiting approval, or idle_prompt after a completed response. Desktop notifications need a supported terminal or notification service.

STOP / TASKCOMPLETED

Check completion

Require evidence of a requested deliverable. Check a specific condition rather than repeatedly launching a broad suite after every small change.

POSTMODELSWITCH

Adapt model guidance

Supply short model-specific context after a session switches models. The hook cannot block a change that already happened.

Background checks without blocking the agent

Set async: true on a command handler for logging, notifications, or checks that can finish later. Returned context is delivered on a subsequent turn. It cannot veto an action or undo execution.

asyncRewake: true can wake Claude when a background handler exits 2, passing the error back so it can react. Ordinary async processes do not enforce the configured timeout once backgrounded and may be cancelled during headless session teardown.

Do not use either form for the grep gate. Its decision must arrive before the shell command runs.

Environment setup, watchers, and custom workspaces

SessionStart, Setup, CwdChanged, and FileChanged can write exported variables to CLAUDE_ENV_FILE for later Bash calls. Directory-specific values from CwdChanged/FileChanged clear at the next directory change.

Seed watchPaths with absolute paths from SessionStart or CwdChanged, then react through FileChanged. A handler that rewrites a watched file must be idempotent to avoid loops.

WorktreeCreate replaces the default checkout mechanism. Actually create a usable workspace and return its normalized path; pair it with WorktreeRemove for cleanup when using a non-git system.

08 / LIMITS & DEBUGGING

Test the gate.
Including when it fails.

  1. Hooks run with your privileges. Review scripts and dependencies. Do not assume hook actions receive the same sandboxing or approval prompts as model tool calls.
  2. Most failures are not denials. A missing executable, exit 1, pre-tool timeout, or HTTP connection failure can leave execution permitted. HTTP rejection needs a successful HTTP response containing valid decision JSON; 403 alone is not a block.
  3. Post-processing is not prevention. Result replacement cannot undo file writes or network requests. MessageDisplay edits only the visible assistant text, not stored transcripts.
  4. Keep handlers idempotent and input-safe. Parse JSON as data, use argv-based subprocess calls, coordinate shared files, and avoid self-triggering watcher loops.
  5. Protect sensitive data. Hook payloads and logs can contain prompts, arguments, and results. Send only what is needed to external endpoints. Use managed policies and OS controls for hard security boundaries.

Verify in your installation

  1. Run /hooks to inspect loaded handlers and their source. It is a read-only browser.
  2. Feed synthetic event JSON to the script. Check direct commands, compound commands, malformed input, dependency failures, and clean stdout.
  3. Launch with claude --debug-file /tmp/claude-hooks.log, then exercise a safe allowed and denied tool call.
  4. For one troubleshooting run, use claude --settings '{"disableAllHooks": true}'. This does not disable administrator-managed hooks.

Settings edits are normally picked up by the watcher. Missing paths and interpreter failures are visible in debug logs. A successful subprocess check proves the script logic—not that Claude Code loaded your configuration.

SOURCES / 16 SEPTEMBER 2026

Check the contract.

This guide summarizes current official documentation. Your installed release, host application, administrator policy, and permission mode can affect availability and behavior.

Download Python sample ↓Download settings fragment ↓Save this guide as HTML ↓

The guide embeds its fonts, styles, examples, and interactions. Reading, filtering, and the outcome explorer work offline; documentation links need a connection. Clipboard availability depends on the browser. Download the sample files before going offline. No analytics or network calls from the interactions. IBM Plex fonts: SIL Open Font License embedded in the HTML.