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.
FIELD GUIDE 01 / CLAUDE CODE HOOKS
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 sampleOfficial 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
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.
| Mechanism | Job |
|---|---|
| CLAUDE.md / skills | Guide what Claude tries to do. |
| Permissions | Allow, ask, or deny matching tool calls. |
| Hooks | Run custom logic at defined lifecycle points. |
| Sandbox / OS controls | Enforce 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.
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.
| Native permission capability | PreToolUse limitation |
|---|---|
| Hide a tool entirely | A 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 process | A 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 policy | A hook’s allow decision cannot override matching deny or ask rules, including managed restrictions. |
| Handle separate network approvals | The 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
| Type | What runs | Use it for |
|---|---|---|
command | Your script or executable; event JSON arrives on stdin. | Precise rules, formatters, checks, notifications. |
http | An HTTP POST with the event JSON; response JSON supplies the decision. | A central approval or audit service. |
mcp_tool | A tool on an already-connected MCP server. | An existing integration or policy tool. |
prompt | One model evaluation, using a fast model by default. | Judgment based on the supplied event data. |
agent | A 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.
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
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.
No events match these filters. Clear the search or choose another group.
SessionStart Load context when a session starts or resumes. Context and setupTrigger 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.
Setup Run explicitly requested initialization or maintenance. Setup onlyTrigger 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.
InstructionsLoaded Observe instruction files entering context. Observe onlyTrigger 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.
DirectoryAdded Prepare a working directory added mid-session. After-add setupTrigger 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.
SessionEnd Clean up when a session terminates. Cleanup onlyTrigger 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.
PreToolUse Inspect or gate a tool call before execution. Block or rewriteTrigger 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.
PermissionRequest Decide a tool permission request. Allow or denyTrigger 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.
PostToolUse Inspect or replace a successful tool result. Feedback or output rewriteTrigger 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.
PostToolUseFailure Provide feedback after an executing tool fails. Failure contextTrigger 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.
PostToolBatch React once after a batch of tool calls resolves. Context or stop loopTrigger 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.
UserPromptSubmit Validate a prompt before Claude processes it. Block or add contextTrigger 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.
UserPromptExpansion Gate a user-typed command's prompt expansion. Block or add contextTrigger 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.
MessageDisplay Transform assistant text as it is displayed. Visual replacement onlyTrigger 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.
Notification Forward a notification through your own handler. Notify onlyTrigger 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.
PermissionDenied Observe an auto-mode denial or suggest retrying. Auto-mode retry hintTrigger 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.
SubagentStart Give a starting or resumed subagent context. Subagent contextTrigger 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.
SubagentStop Check whether a subagent should finish responding. Keep subagent workingTrigger 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.
TaskCreated Enforce criteria for a newly created task. Reject task creationTrigger 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.
TaskCompleted Require evidence before a task is marked complete. Gate task completionTrigger 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.
TeammateIdle Check a teammate before it becomes idle. Continue or stop teammateTrigger 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.
Stop Check whether the main agent should finish its turn. Continue the conversationTrigger 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.
StopFailure Observe a turn ending because of an API error. Error notification onlyTrigger 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.
PreCompact Inspect or block an impending context compaction. Can block compactionTrigger 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.
PostCompact Observe the generated compaction summary. Observe onlyTrigger 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.
ConfigChange Audit or gate configuration-file changes. Block non-policy reloadsTrigger 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.
PreModelSwitch Gate a user- or client-requested model switch. Block or askTrigger 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+.
PostModelSwitch Add guidance after the session's model changes. Model-specific contextTrigger 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+.
CwdChanged Refresh the environment after a directory change. Environment and watchesTrigger 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.
FileChanged React when a watched file changes on disk. Filesystem observationTrigger 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.
WorktreeCreate Replace default creation of an isolated working copy. Custom creation contractTrigger 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.
WorktreeRemove Clean up a custom isolated working copy. Cleanup result controls removalTrigger 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.
Elicitation Handle an MCP server's request for user input. Respond or declineTrigger 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.
ElicitationResult Inspect an elicitation response before the server gets it. Rewrite or decline responseTrigger 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.
04 / CONFIGURATION
Add a hooks object to your settings. Merge entries into the existing configuration; do not replace unrelated settings.
| Location | Scope |
|---|---|
~/.claude/settings.json | Your projects globally. |
.claude/settings.json | Shared project configuration. |
.claude/settings.local.json | Local project configuration. |
| Managed settings | Administrator-controlled policy. |
| Plugins / skill / subagent frontmatter | Packaged 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.
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check.py"],
"timeout": 5
}]
}]
}
}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.
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.
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
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.
{
"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"
}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.
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."
}
}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.
| Capability | Output and limits |
|---|---|
| Modify tool arguments | PreToolUse hookSpecificOutput.updatedInput replaces the whole input object. Keep unchanged fields you still need; permissions evaluate the replacement. |
| Replace a tool result | PostToolUse updatedToolOutput inside hookSpecificOutput must match the output schema. The tool already ran; telemetry may retain the original. |
| Inject context | Supported events accept hookSpecificOutput.additionalContext. Prefer factual environment context; static instructions belong in CLAUDE.md. |
| Make work continue | Stop decision: "block" with a reason blocks stopping. Or return Stop additionalContext for non-error feedback. Check stop_hook_active; continuation is capped. |
| Stop processing | continue: false with stopReason stops processing on events that honor it. This is the opposite of blocking a Stop event. |
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
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.
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.
mkdir -p "$HOME/.claude/hooks"
python3 -m venv "$HOME/.claude/hooks/venv"
"$HOME/.claude/hooks/venv/bin/python" -m pip install 'bashlex==0.18'
Save the code below as ~/.claude/hooks/grep-pipe-only.py, or download grep-pipe-only.py and place it at that path.
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}")
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.
{
"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.
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.
| Proposed Bash command | Hook outcome | Reason |
|---|---|---|
grep pattern file.txt | Deny · exit 2 | Direct grep has no incoming pipe. |
producer | grep pattern | No decision · exit 0 | Grep is a receiving stage; normal permissions still apply to the producer. |
grep pattern file.txt | sort | Deny · exit 2 | Grep is the producer, not a receiving stage. |
grep 'a|b' file.txt | Deny · exit 2 | A quoted pipe character is not a pipeline operator. |
producer | grep pattern; grep other file.txt | Deny · exit 2 | A 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.
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.producer | (grep pattern) puts a compound node after the pipe, so the nested grep is not recorded as a receiving command.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.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.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
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.
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.
Use permission_prompt for a waiting approval, or idle_prompt after a completed response. Desktop notifications need a supported terminal or notification service.
Require evidence of a requested deliverable. Check a specific condition rather than repeatedly launching a broad suite after every small change.
Supply short model-specific context after a session switches models. The hook cannot block a change that already happened.
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.
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
/hooks to inspect loaded handlers and their source. It is a read-only browser.claude --debug-file /tmp/claude-hooks.log, then exercise a safe allowed and denied tool call.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
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.