If you have read the Claude Code Hooks documentation, the example you remember is probably one that prints to stderr and exits 0. That example is correct, and it is where most tutorials stop. The four exit codes that decide whether your hook is safe in production are the ones the docs barely mention.
We run a PreToolUse hook on our own monorepo every working day, and the shape it settled into is not what we expected when we wrote the first version. What looked like a single conditional turned out to be a state machine with four branches. The hook grew into that shape because each of the four cases meant something different to a production team, and collapsing any two caused real bugs.
The shape of a PreToolUse hook
A PreToolUse hook is a script Claude Code runs immediately before a tool call — a Bash command, a file write, an MCP invocation. It receives the tool input as JSON on stdin and can do three things: pass the call through, modify the input, or signal deny or human confirmation. It communicates through two channels: an exit code, and an optional JSON object on stdout.
Most introductory examples show the simplest case — a script that prints a log line, exits 0, lets the command run. That is fine for personal use. Once a second person uses your config, or once a single hook governs commands you actually care about — anything that touches production data, costs money, or is hard to undo — the simple case is not sufficient.
The hook we run came out of a different need. We were paying a non-trivial token bill on certain CLI calls (git status, git diff, find), and wanted to silently rewrite them to use a proxy that returns trimmed output. The first version was a one-liner that always rewrote and always allowed. It broke within a day. Some commands had no equivalent rewrite. Others were dangerous enough that we did not want to silently approve them. By the time the hook stabilized, it had four branches.
The snippets below are excerpted from rtk-rewrite.sh, the open-source Claude Code hook shipped by rtk-ai/rtk under Apache 2.0. The full source is at github.com/rtk-ai/rtk. "rtk" is a trademark of that project; we use the name only to attribute the source. We picked it because it ships an explicit four-state exit-code protocol in its file header, which is rare in published hook code.
The expressive case — allow with rewrite (exit 0)
The first branch does the most work and is the one most tutorials skip. The hook receives a command, decides how to run it, rewrites the input, and tells Claude Code to skip user confirmation.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": { "command": "rtk git status" }
}
}
Three details inside this object look minor and turn out to be load-bearing. permissionDecision: "allow" tells Claude Code to skip the user prompt entirely. It is the right default for a rewrite that is genuinely safe and transparent, and the fastest way to disable the permission model if used carelessly. We have not yet seen a hook that started with three allow branches and ended up with fewer than five within a month.
permissionDecisionReason ends up in your audit logs. It is the line your future self reads at 11pm on a Friday when something has gone wrong and you are trying to remember why a particular Bash call was waved through three weeks ago. Leaving it empty is technically valid. It is also the audit-log equivalent of a commit with the message "stuff."
updatedInput is the part most early hook designs do not use. The hook is not just deciding allow versus deny — it is rewriting the command into something different, then allowing the rewritten version. A raw rm -rf can be silently translated into a safer-rm wrapper before approval. The original command never runs.
This branch deserves the name "expressive" because you can encode genuinely useful policy in it. Trim large outputs. Route a CLI call through a caching proxy. Replace a destructive command with a reversible one. The cost is the discipline of filling in permissionDecisionReason. Skip that, and the branch becomes a black box.
The honest case — passthrough when you have no opinion (exit 1)
The second branch does nothing, and writing it correctly is harder than it sounds. Exit code 1 means the hook examined the command and has no opinion. Claude Code's native flow takes over, as if the hook had never been installed.
case $EXIT_CODE in
0) [ "$CMD" = "$REWRITTEN" ] && exit 0 ;;
1) exit 0 ;; # No equivalent rewrite — let it pass.
...
esac
That exit 0 after the comment looks like a copy-paste error. It is not. The shell hook exits cleanly with no JSON on stdout — that is how you tell Claude Code "I have nothing to say." The internal exit-1 from the underlying binary is private signaling Claude Code never sees. Outside the hook, the only contract is the shell exit and the optional JSON.
The alternative is a hook that claims jurisdiction over commands it does not understand. We have seen exactly one bug class in this layer, always the same shape: a hook accidentally treated an unknown command as if it had a rewrite, returned a malformed updatedInput, and Claude Code dutifully ran a garbage command. The fallout was twelve minutes of Bash calls returning empty strings before we noticed. When in doubt, exit zero with no JSON.
The handoff case — let Claude Code's own deny rule decide (exit 2)
The third branch is where production teams tend to over-engineer. When a hook detects something dangerous, the temptation is to deny it directly. The hookSpecificOutput object will let you do exactly that. We do not.
2)
# Deny rule matched — let Claude Code's native deny rule handle it.
exit 0
;;
The branch detects that a command matches a deny rule, then exits cleanly, prints no JSON, and lets the call continue past the hook. The actual deny is handled downstream by Claude Code's permissions.deny configuration, which has its own UI, audit format, and override mechanism.
Two reasons we route this way. Deny rules in permissions.deny are configured in the same place as the rest of the team's permission policy — a teammate can see, in one file, what is denied. A regex buried in a Bash conditional is not reviewable. It decays into folklore within two months.
The override path also matters. When Claude Code denies via its native rule, the user can negotiate — re-prompt, request an exception, escalate. A hook that denies directly via permissionDecision: "deny" makes the path back more awkward and lands the audit entry under a different category. So the hook signals "deny territory" with internal exit code 2, then steps back.
The interesting case — rewrite, then hand the question to a human (exit 3)
The fourth branch is the one we use most often, and the one we did not realize we needed until the hook had been running for two weeks. It rewrites the command the way the first branch does, but omits permissionDecision. Claude Code sees the rewritten input, sees no decision, and prompts the user.
3)
# Ask: rewrite the command, omit permissionDecision so Claude Code prompts.
jq -n \
--argjson updated "$UPDATED_INPUT" \
'{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": $updated
}
}'
;;
That single absence — no permissionDecision field — is the entire mechanism. The hook is saying "I have shaped the command, but I am not approving it." Claude Code interprets the missing field as a request for human confirmation, shows the user the rewritten command, and waits.
The interesting commands in our monorepo are not the ones we want to kill, and not the ones we want to auto-allow. They are the ones we want a teammate to look at for two seconds before letting through. A migration that touches production. A delete against a shared bucket. A force-push to a branch that is sometimes deployed. The hook does the boring work — substitute the right wrapper, add the right flags, point at the right environment — then hands a clean command back to a human for the final yes.
The first branch is for rewrites so safe that asking would be insulting. The fourth is for rewrites that are helpful but not safe enough to auto-approve. Production hooks live in the fourth branch more than the first.
What surrounds the four states
The four-state machine is the core, but the hook in production is held together by three smaller habits. They sound like operational hygiene; they are what keeps the state machine from failing quietly.
The kill switch came first. It is four lines at the top of every agent script we ship, and predates the hook itself. It checks for a sentinel file at a known path, and exits cleanly if it exists.
if [ -f /tmp/agentkit-ccpack-pause ]; then
echo "[kill-switch] Paused. Exiting."
exit 0
fi
We added this after a separate side project of ours ran six days of unsupervised automation — technically correct on every individual loop, catastrophically wrong as a whole. What was missing was a way to pull the plug from outside. A four-line check is the difference between a six-day incident and a thirty-second one — any agent on the machine can be stopped by one touch, no code changes required.
The version guard followed within a week. The hook depends on a particular version of an external binary, and that binary has shipped breaking changes. The guard parses the version string and exits cleanly with a stderr warning if it is too old.
RTK_VERSION=$(rtk --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
if [ -n "$RTK_VERSION" ]; then
MAJOR=$(echo "$RTK_VERSION" | cut -d. -f1)
MINOR=$(echo "$RTK_VERSION" | cut -d. -f2)
if [ "$MAJOR" -eq 0 ] && [ "$MINOR" -lt 23 ]; then
echo "[rtk] WARNING: rtk $RTK_VERSION is too old (need >= 0.23.0)." >&2
exit 0
fi
fi
Without this, an old binary keeps the hook running with the wrong semantics. Deny rules might be evaluated against the new command shape; rewrites might target obsolete flags. The hook does not crash — it just gets quietly wrong. The guard is the hook's way of refusing to run when the ground has shifted under it.
The dependency guard was the last to land, after we lost an evening to a missing jq. The hook checks that its required tools are on the path, and warns to stderr and exits cleanly if any are missing. Not exit 1, which would propagate as a hook failure and freeze the session. Exit 0, with a visible warning, so the session stays alive while the hook becomes a no-op.
if ! command -v jq &>/dev/null; then
echo "[rtk] WARNING: jq is not installed. Hook cannot rewrite commands." >&2
exit 0
fi
Silent failure is the largest single bug source we have seen in production hooks. A graceful no-op is louder than a crash — the warning shows up every run — but less destructive than a frozen Bash call. The pattern across all three guards is the same: fail open with a visible warning, never fail closed in silence.
Putting the four states together
The hook is not a script that prints something. It is a state machine with four exits, and a wrapper of operational guards around them.
command in
│
▼
[hook logic]
│
├─ exit 0 + JSON ────▶ allow + (optional rewrite)
├─ exit 1 ───────────▶ passthrough (no JSON)
├─ exit 2 ───────────▶ deny-handoff (let Claude Code's deny rule act)
└─ exit 3 + JSON ────▶ ask (rewrite, but human confirms)
Once the four states are visible, the implementation almost asks for a particular shape. Judgment — which state are we in for this command — wants to live somewhere testable. Response shaping — printing the right JSON, choosing the right exit code — wants to live in the shell. The rtk hook puts judgment in a Rust binary and lets the shell do I/O. The shell hook is the postman. The binary is the brain. Not every team needs a Rust binary, but the separation between "decide" and "respond" is what lets you write tests against decision logic at all. Decisions written in Bash conditionals are decisions you cannot verify.
Where this leaves a production team
Once you see the four branches, you stop writing hooks that pretend to be opinions and start writing hooks that are postmen. Allow with a real reason. Pass through honestly when you have no opinion. Hand denies back to the platform's own rule. Rewrite-then-ask for the cases where a human still needs the final word.
Claude Code Hooks are a fine extension layer. Not every team needs a Rust binary, and not every config needs all four branches on day one. But the four-state shape is universal, and the day you wish you had separated judgment from I/O is the day someone runs rm -rf against staging.
Not legal advice. The patterns here come from our own production usage; your config will need review by someone with context on your team's actual risks.
We are open-sourcing the AgentKit Hooks Pack — production-ready templates for PreToolUse permission gating, PostToolUse audit logs, kill switch sentinels, notification routing — under Apache 2.0 in late May. The day it lands, we email a launch note plus the Companion Guide PDF (sixty pages on lifecycle events, failure modes, and rollout patterns) to the pre-launch list. To get on it: imta71770-dot.github.io/agentkit-hooks-pack. The repo itself, with the README and license, lives at github.com/imta71770-dot/agentkit-hooks-pack.
Code snippets in this article are excerpted from rtk-rewrite.sh in rtk-ai/rtk, licensed under Apache 2.0. "rtk" is a trademark of that project and is used here for attribution only.
Top comments (0)