HookStack
All guides

PreToolUse vs PostToolUse: Which Claude Code Hook to Use

7 min read · Reviewed 2026-06-12

PreToolUse and PostToolUse are the two most-used Claude Code hook events — and the difference between them decides whether you can prevent a bad action or only react to it.

This guide explains exactly what each event does, shows a complete blocking PreToolUse hook next to a complete PostToolUse formatter, walks through their differing stdin payloads, covers matcher syntax, and gives you a one-line rule for choosing.

What is the difference between PreToolUse and PostToolUse?

PreToolUse runs before a tool executes. It sees the tool input — the command, the file path, the URL — and it can block the action entirely by returning a block decision. Nothing happens until your hook says yes.

PostToolUse runs after the tool has already completed. It receives the tool’s result and can react — format the file, run a linter, log the command — but it cannot undo what just happened. By the time it runs, the change exists.

What does a PreToolUse hook look like?

A PreToolUse hook inspects tool_input and returns a block decision to stop the action. This one — adapted from the HookStack secret-detection hook — refuses any Bash command that looks like it contains an API key:

// .claude/hooks/pre-bash-secret-detection.mjs
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'

const SECRET_PATTERNS = [
  /sk-(?:ant-|proj-)?[a-zA-Z0-9_-]{32,}/,        // Anthropic / OpenAI keys
  /ghp_[a-zA-Z0-9]{36}/,                          // GitHub PAT
  /(?:password|secret|token)\s*=\s*['"][^'"]{6,}/i,
]

export function run(input) {
  if (input.tool_name !== 'Bash') return null
  const command = input.tool_input?.command ?? ''
  const hit = SECRET_PATTERNS.find((p) => p.test(command))
  return hit
    ? { decision: 'block', reason: 'Possible secret in the command. Move it to an env var before running.' }
    : null
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const input = JSON.parse(readFileSync(0, 'utf8'))
  const result = run(input)
  if (result) process.stdout.write(JSON.stringify(result))
}

A PreToolUse hook blocks by writing { "decision": "block", "reason": "…" } to stdout. Always give an actionable reason — the model reads it and can correct course. (The runtime also supports a stricter hookSpecificOutput.permissionDecision: "deny" form; the top-level decision shown here is the convention HookStack ships.)

What does a PostToolUse hook look like?

A PostToolUse hook reacts to a completed change. This one lints a file right after it is written or edited, surfacing any problems on stderr so the next step can fix them. Note the extension filter — you do not want to launch Biome on a Markdown file:

// .claude/hooks/biome-check.mjs
import { readFileSync } from 'fs'
import { execSync } from 'child_process'
import { fileURLToPath } from 'url'

export function run(input, { exec = (c) => execSync(c, { stdio: 'pipe', timeout: 15_000 }) } = {}) {
  const filePath = input.tool_input?.file_path ?? ''
  if (!filePath || !/\.[cm]?[jt]sx?$/.test(filePath)) return null // only JS/TS files

  try {
    exec(`npx --no-install biome lint --error-on-warnings "${filePath}"`)
    return null // clean
  } catch (err) {
    const output = err.stdout?.toString() ?? ''
    return output ? { message: `Biome: ${output.trim()}\n` } : null
  }
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const input = JSON.parse(readFileSync(0, 'utf8'))
  const result = run(input)
  if (result?.message) process.stderr.write(result.message)
}

Because the change already landed, the hook’s job is to make problems visible, not to prevent them. It writes the lint output to stderr; Claude reads it and fixes the file on the next turn.

How do the stdin payloads differ?

Both events receive the common fields (session_id, cwd, hook_event_name, tool_name, tool_input). The difference is that PostToolUse adds a tool_response field with the result of the tool that just ran:

// PreToolUse — before the tool runs (no result yet)
{ "hook_event_name": "PreToolUse", "tool_name": "Write",
  "tool_input": { "file_path": "src/api.ts", "content": "…" } }

// PostToolUse — after the tool ran (result included)
{ "hook_event_name": "PostToolUse", "tool_name": "Write",
  "tool_input":    { "file_path": "src/api.ts", "content": "…" },
  "tool_response": "File written successfully" }

That extra tool_response is exactly why PostToolUse can react to outcomes — it knows whether the write succeeded and what the tool returned.

How do matchers decide which tool a hook runs on?

The matcher on each hook group filters by tool name. The syntax is small:

  • "Bash" — exact match, runs only on Bash calls.
  • "Write|Edit" — pipe-separated list, runs on either Write or Edit.
  • "*" (or omitting the matcher) — runs on every tool.
  • Anything with a special character is treated as a JavaScript regex — e.g. "mcp__memory__.*" matches every tool from the memory MCP server.

So a secret-detection PreToolUse hook uses "Bash", while a formatter PostToolUse hook uses "Write|Edit" because both write to disk.

Are PostToolUse hooks really non-blocking?

Mostly yes — but be precise about why. By the time PostToolUse runs, the change already exists, so it cannot un-write a file. It can, however, still return a block decision that tells Claude to stop and reconsider. What makes HookStack’s PostToolUse hooks “non-blocking” is an implementation convention, not a runtime guarantee: they wrap external tools in a silent try/catch so a missing binary (Biome not installed, for example) just exits quietly instead of breaking your session. If you write your own PostToolUse hook that throws or returns a block decision, it will interrupt the flow.

A simple decision rule

  • Need to prevent or validate an action? → PreToolUse.
  • Need to react to a completed action? → PostToolUse.

The two compose well: a PreToolUse guard stops dangerous writes, while a PostToolUse formatter cleans up the writes that are allowed.

Frequently asked questions

Is PreToolUse a blocking hook?
Yes. PreToolUse runs before the tool and can block it by returning { "decision": "block", "reason": "…" } on stdout. PostToolUse runs after the change has landed, so it cannot prevent it.
What is the difference in the stdin payload?
Both get tool_name and tool_input. PostToolUse additionally receives tool_response — the result of the tool that just ran — which is why it can react to outcomes.
Are PostToolUse hooks always non-blocking?
Not inherently. HookStack PostToolUse hooks exit quietly on missing tools by convention (silent try/catch), but the runtime still lets a PostToolUse hook return a block decision. The non-blocking behavior is a design choice, not a hard rule.
Can I use both events together?
Yes, and it is a common combination — PreToolUse to guard, PostToolUse to format and check.

Related hooks

Sources

Read next