Hook event 07 of 33

PreToolUse

Runs after Claude forms a tool's parameters and before Claude Code processes the tool call.

Exit code veto
Exit 2 blocks
Matchers
Built-in or MCP tool names, exact or regex
if filter
Applies
Docs checked
2026-08-29

The last moment a tool call can be changed

Claude has already picked a tool and filled in the arguments. Nothing has run yet. That gap is where PreToolUse fires, and it is the one event where you can still rewrite the call or refuse it outright. The hooks reference names one built-in that skips it, EndConversation, and one non-tool case: inserting a file into a prompt with @ does not fire it.

If the word you are reaching for is prevent, this is the page. PostToolUse fires after the fact and can annotate a result, never undo one.

The matcher picks the tool, and if skips the process

Your hook should not run on every tool call in the session. The matcher names a tool: Bash, Read|Bash, or a regex such as mcp__memory__.*. Which of those two readings you get depends on the characters in the string, and module 09 covers the trap in that.

The if filter holds one permission-rule pattern, such as Bash(rm *), and Claude Code checks the tool name and its arguments against it before spawning your process. When it cannot parse the command well enough to compare, it runs the hook anyway. So the filter saves a process spawn on unrelated calls. It is not the check. The check is inside the hook.

What each tool puts in tool_input

Before you read a field, you need to know which tool shape you are looking at. The payload carries permission_mode, tool_name, tool_use_id, and a tool_input object whose shape depends on the tool. The reference documents the nested inputs for the built-ins:

Tooltool_input fields the reference shows
Bash, PowerShellcommand, optional description, timeout, run_in_background
Writefile_path, content
Editfile_path, old_string, new_string, replace_all
Readfile_path, offset, limit
Globpattern, path
Greppattern, path, glob, output_mode, -i, multiline
WebFetchurl, prompt
WebSearchquery, allowed_domains, blocked_domains
Agentprompt, description, subagent_type, model
AskUserQuestionquestions, optional answers
ExitPlanModeplan, planFilePath, deprecated allowedPrompts

Tools from an MCP server arrive under their mcp__server__tool name with whatever input the server defined. A hook that reads tool_input.command on a Read call gets nothing, which is why the lab’s block_secrets checks tool_name before it looks at any field.

Four answers and a rewrite

Now we need the shape of the answer. The JSON lives under hookSpecificOutput:

  • permissionDecision: allow, deny, ask, or defer.
  • permissionDecisionReason: shown to you on allow and ask, handed to Claude on deny.
  • updatedInput: a whole replacement for tool_input, so the call runs with different arguments.
  • additionalContext: text added to what Claude sees.

A top-level decision and reason pair still exists for this event, but the reference marks it deprecated, so write the nested shape. That shape belongs to PreToolUse. PermissionRequest decides through a top-level decision object instead, and the two are not interchangeable.

Exit 2 outranks the JSON

Exit codes and JSON both speak here, and they do not carry equal weight. Exit 0 with nothing on stdout is no decision. The call goes on to the normal permission flow, where your rules and prompts apply as usual. Exit 0 with valid JSON applies the JSON. Exit 1 does not block on its own, but valid JSON on stdout still decides, so a hook that prints a deny and then exits 1 has still denied.

Exit 2 blocks the call. Claude receives the permissionDecisionReason if there is one and stderr otherwise, and no JSON field turns an exit 2 back into an allow. That ordering is why the lab ships two hooks for this event. block_destructive exits 2 with one stderr sentence. block_secrets denies through JSON and exits 0 either way.

The mistake most people make first

You write the deny reason to stdout as a sentence rather than JSON, and exit 0:

if SECRET.search(path):
    print(f"Refusing: {path} is a secret file.")
sys.exit(0)

Claude reads the file. On exit 0, plain stdout goes to the debug log, so the sentence reached nobody, and exit 0 with no JSON is no decision. The call went to the normal permission flow, and the permission flow had no rule against it. Nothing on screen reports a hook error, because there was no error. The hook succeeded. Yes, really.

Print the JSON shape above, or exit 2 with the sentence on stderr. Module 09 walks you into the exit 1 version of this, which fails the same silent way.

Module 11 places the event in the permission order: hooks run before the rules, and a hook allow still does not skip a deny rule.