claudeplugins.
claudeplugins10 min read

How PostToolUse Hook Exit Codes Block Claude Code Execution

The PostToolUse hook is a script Claude Code shells out to after every tool call. Its exit code is the entire control surface — exit 2 blocks the assistant and feeds stderr back into the next turn.

What the PostToolUse Hook Actually Is

How can a hook that runs after a tool call possibly change what the assistant does next? The first time I wired up a PostToolUse hook in Claude Code, I assumed it was a glorified logger. I had it write a line to a file every time the assistant ran a tool, felt clever for about an hour, and then quietly forgot it existed. It took me another two weeks to realize I'd been holding the most useful lever on the entire hooks system upside down.

Here's what's actually happening. When you run Claude Code in a terminal, the assistant doesn't just talk — it calls tools. It reads files, edits files, runs shell commands, queries the web, dispatches subagents. Each of those tool invocations is observable, and the hooks system gives you a way to insert your own shell commands at well-defined moments in that observation cycle. The PostToolUse hook is the one that fires immediately after a tool call has finished but before the assistant has had a chance to see, summarize, or act on the result.

That timing matters more than it sounds. A PreToolUse hook can veto a tool call before it happens. A Stop hook can intervene when the model decides it's finished. PostToolUse sits in the narrow window where the tool has already produced a result and the assistant has not yet woven that result into its next turn. The hook gets the chance to look at what just happened, decide whether the project is still in a state you tolerate, and either let the assistant continue or force a course correction.

The exit code of the script you wire into that hook is what carries the verdict. Zero means "you may proceed." Non-zero means "stop, something is wrong, and the assistant should be told why." That single integer is the entire control surface, and it's also the entire point of this article: a non-zero exit code from a PostToolUse hook script doesn't merely log a warning — it actively blocks the assistant from proceeding as if the tool call succeeded cleanly.

Why Exit Codes Are the Hook's Voice

A friend spent an afternoon hunting for the SDK, the plugin manifest, the lifecycle interface; none exist, because a hook isn't a plugin at all. It's a shell command that Claude Code shells out to with a JSON payload on standard input. That payload describes the tool that just ran, the arguments it received, and the response it produced. Your script reads the payload, decides something, and signals its decision the only way a process can signal anything to its parent: through standard output, standard error, and an exit code.

This is the same protocol every Unix tool uses. The same protocol git uses for pre-commit and pre-push hooks. The same protocol systemd uses to decide whether a unit succeeded. The reason Claude Code chose this protocol is that it composes with everything: you can write your PostToolUse hook in Bash, Python, Node, Rust, or any executable, and the rules don't change. The full hooks specification, including the JSON shape Claude Code passes in and the exit-code semantics it expects out, is documented at the official Claude Code hooks reference.

The contract is small and worth memorizing. Exit code zero is the "all clear" path. Exit code two is the "block and feed stderr back to the model" path — the model sees whatever your script wrote to standard error and is forced to react to it before continuing. Any other non-zero exit code is treated as a generic error: the hook failed, the user sees the error, but the model doesn't necessarily get the structured feedback that exit code two delivers.

The distinction between "two" and "anything else" is the one most people miss the first time they wire a hook. I missed it. Two of my coworkers missed it. It's the distinction that determines whether your hook is teaching the model or just annoying the operator, and it's worth tattooing somewhere visible.

How a Non-Zero Exit Code Blocks Execution

Here's a puzzle: the Edit tool wrote the bytes, the Bash command ran cleanly, the tool result is real — so what does it mean to say the hook "blocks execution"? A PostToolUse hook that always returns zero is a passive observer — useful for logging, telemetry, or push notifications, but invisible to the model. A PostToolUse hook that can return non-zero is a gate. When it slams shut, the assistant can't pretend the tool call landed cleanly. It has to read the stderr you produced, integrate that feedback into its next turn, and decide what to do about it.

This is what people mean when they say "the exit code blocks execution." The Edit tool did succeed in writing bytes to disk. The Bash command did run. The tool result is a real result. But your hook saw something in that result it didn't like — maybe the edit introduced a syntax error, maybe the bash command tripped a security policy, maybe the file it touched is on a no-edit list — and the hook says "no, you do not get to continue as if this was fine." The assistant gets re-engaged with your stderr in hand and has to address it.

That's the loop the rule unlocks. Linters, type checkers, schema validators, dependency-graph guards, privacy gates, repo-hygiene checks — anything you can express as a script that exits two and writes a message to stderr can become a hard rule the model has to obey. The model doesn't have to be convinced. The model doesn't have to be prompted. The exit code does the work.

A Minimal Example

Below is the kind of hook configuration that demonstrates the pattern most directly. The settings file lives at the project root or in the user's home configuration directory, and it tells Claude Code which scripts to invoke for which tool calls. A more complete walkthrough of the configuration file structure lives in the Anthropic Claude Code repository on GitHub, which is the upstream reference for everything the CLI does, hooks included.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "scripts/check_edit.sh"
          }
        ]
      }
    ]
  }
}

The matcher decides which tool calls the hook applies to — here, only the Edit and Write tools. When the model invokes either, Claude Code pipes a JSON payload to the script and waits for it to finish. The script is responsible for the actual decision logic. A small example of one such script:

#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
path="$(echo "$payload" | jq -r '.tool_input.file_path')"
if [[ "$path" == *".env"* ]]; then
  echo "Edits to .env files are not allowed by this project's policy." >&2
  exit 2
fi
exit 0

The exit code two is what makes this a gate, not a warning. If you swapped that for exit 1, the assistant would see a generic hook failure and the operator would see a noisy error message, but the model wouldn't necessarily receive the structured stderr feedback it needs to recover gracefully. Exit code two is the disciplined answer.

Where This Pattern Pays Off

The single most common payoff is enforcing project-level rules that the assistant tends to forget under load. Every nontrivial codebase has invariants the team has agreed on — file size ceilings, nesting depth limits, forbidden imports across architectural layers, secret patterns that must never appear in published content, commit message formats, naming conventions. The assistant knows about these rules because they live in your project documentation, but knowledge isn't the same as compliance. A hook that exits two when an invariant is violated converts knowledge into compliance.

The second payoff is feedback loops on long-running agentic sessions. When the assistant is grinding through a multi-step task — refactoring across files, scaffolding a service, migrating a schema — small errors accumulate quietly. A PostToolUse hook that runs a fast type check, a quick lint pass, or a sanity test on the touched file converts those silent accumulations into immediate, model-visible failures. The model gets to fix the mistake while the context is still warm, instead of waiting for a human to discover it after the session ends.

The third payoff is operator confidence. Most people who run Claude Code on real projects develop a quiet anxiety about what the assistant might do while their attention is elsewhere. Hooks that block on dangerous patterns — touching protected files, calling production endpoints, deleting more than a tolerable number of files in one go — convert that anxiety into a guarantee. You stop worrying because the gate stops the assistant before the dangerous action can land.

Common Pitfalls

The first pitfall is mixing up "exit two" and "any non-zero exit." If you don't exit two, the assistant doesn't get the structured stderr feedback it needs to recover. Your script may have done the right thing, but the model won't learn from it. Always exit two when you want the model to react.

The second pitfall is making the hook too slow. Every PostToolUse hook adds latency to every matching tool call. A 200ms hook is invisible; a 5-second hook is felt. If you need to run a heavy check, debounce it, cache aggressively, or move it to a Stop hook that runs once at the end of the session instead of on every Edit.

The third pitfall is writing the wrong message to stderr. The model reads your stderr verbatim. Vague messages produce vague recoveries. "Edit rejected" teaches nothing; "Edits to files under config/ require the operator to update the schema first" teaches the model what to do next. Treat the stderr like a system prompt fragment, because that's functionally what it is.

The fourth pitfall is letting hooks veto things they have no business vetoing. A hook that fires too broadly turns into a productivity tax. Use the matcher to narrow the hook to the tool calls where its judgment is actually needed.

When Not to Use PostToolUse to Block

PostToolUse is the wrong place to refuse a tool call you could have refused earlier. If you know in advance that the assistant should not touch a path, that decision belongs in a PreToolUse hook, where the tool call never happens at all. PostToolUse exists for verdicts that depend on the result of the call — verdicts you couldn't have known until the bytes were written, the command ran, or the response came back. Blocking after the fact when you could have blocked before the fact is wasted work and possibly damaging if the tool had side effects.

PostToolUse is also the wrong place to perform observability work that doesn't need to influence the model. Logging, metrics, notifications, audit trails — those should run, succeed silently, and exit zero. Use the blocking exit code only when you genuinely want the model to change its next move.

Conclusion

The PostToolUse hook in Claude Code is a small system with a sharp edge. Its entire control surface is the exit code of a shell command you write. Exit zero and the assistant proceeds. Exit two and the assistant stops, reads your stderr, and reacts. Anything else is a generic error that may not give the model the feedback it needs.

If you take one thing away, take this: the exit code isn't a status report. It's a command. A non-zero PostToolUse exit code is the most direct way an operator has to tell the assistant "no, that result is not acceptable, try again." Used sparingly and with precise stderr messages, it's the smallest, most composable gate the platform offers — and on a codebase that cares about its invariants, it's also the most powerful.