Skip to content

AI Workflow

Hooks Beat Prompts: Deterministic Guardrails for AI Agents

A Prompt Is a Probability. A Hook Is a Guarantee.

🪝 🛑 ⚙️

I have a rule that matters a lot to me: commit straight to main, never create a branch, never open a pull request unless I explicitly ask. It's in my global instructions in bold. It's stated twice.

It got violated twice.

Not maliciously — an agent forty turns deep into a task, holding a large context, hit a default behaviour ("if on the default branch, branch first") and followed it. My rule was in the prompt. The prompt is a probability distribution. On a long enough session, a low-probability path is a certainty.

So I stopped arguing with the distribution and moved the rule into the harness.

The distinction that actually matters

Every instruction you give an agent lives in one of two places, and they have completely different reliability properties:

A prompt is advisory

  • Competes for attention with everything else in context
  • Influence decays as the window fills and the task drifts
  • Loses to more-specific-seeming built-in defaults
  • Followed usually — which is not a guarantee

A hook is executed

  • Runs outside the model, on every matching tool call
  • Fires at 100% regardless of context length
  • Indifferent to how convinced the agent is that this case is special
  • Deterministic — the same input always blocks

The engineering discipline here is old and it's called poka-yoke: design the error out rather than disciplining it away. Teams running automated guardrails report meaningfully fewer production incidents than teams relying on remembering the rule — because remembering is the part that fails under pressure.

The test I now apply: if a rule being broken once would genuinely cost me, it does not belong in a prompt.

What that looks like in practice

My git policy is now both: the sentence in the instructions and a PreToolUse hook that blocks the branch-creating commands before they can run.

# PreToolUse, matcher: Bash — blocks before execution, not after
case "$CMD" in
  *"git checkout -b"*|*"git switch -c"*|*"git worktree add"*)
    echo "BLOCKED: branch creation. Commit straight to main." >&2
    exit 2 ;;
esac

Belt and braces. The prompt explains the intent so the agent doesn't fight the guard; the hook makes the outcome certain when the explanation fails.

The other hooks earning their keep:

  • SessionStart injection. Delivery rules and a "which project is this?" gate injected every single session, so the agent starts calibrated instead of inferring context from a folder name. Facts I need present on turn one can't depend on the agent choosing to read a file.
  • PostToolUse formatting. Formatter runs after every write. Style stops being a thing anyone discusses.
  • Destructive-action gates. Anything irreversible — force-push, migration, bulk delete — has to show what it will affect before it's allowed to proceed.

The worked example: my hook fired on this very post

Here's a thing that happened while writing this series, and it's the best argument for the approach I could have asked for.

I was writing a post that described the git-policy hook — which meant the prose and the code sample contained the branch-creation commands as examples. The hook saw those strings in the tool call and blocked the write. Twice, on two different commands. My guardrail refused to let me write about my guardrail.

Mildly funny, and instructive in three ways.

It was correct. A dumb string matcher fired on the string. It didn't reason about whether I "really meant it" — which is precisely the property I want, because a guard that reasons about intent can reason wrong, and it'll reason wrong in the ambiguous cases where I most need it to be blunt.

The fix was trivial and explicit. Build the literals by concatenation in the generator so the pattern doesn't match, and leave a comment saying why. Ten seconds. The rendered output is identical to what you're reading.

It surfaced immediately. Blocked at the tool call, with a clear message naming the offending command, before anything happened. Compare that to the failure mode of a prompt-only rule, which is silent, discovered later, and already in your history.

A false positive I fix in ten seconds is a far better trade than a false negative I find in git log next week.

The hooks, written out in full

Enough theory. These are the four hooks doing real work in my setup, complete, including the wiring.

1. The git guard. Blocks branch creation and unsafe force-pushes before the command runs:

.claude/hooks/git-policy.sh — PreToolUse, matcher: Bashbash
#!/usr/bin/env bashset -euo pipefailCMD=$(jq -r '.tool_input.command // empty')1block() { echo "BLOCKED: $1" >&2; exit 2; }2case "$CMD" in  *"git checkout -b"*|*"git switch -c"*|*"git worktree add"*)    block "branch creation. Commit straight to main." ;;3  *"push"*"--force"*|*"push"*"-f "*)    case "$CMD" in      *--force-with-lease*) : ;;4      *) block "bare force-push. Use --force-with-lease." ;;    esac ;;  *"git reset --hard"*)    block "reset --hard discards work. Stash or commit first." ;;esacexit 0
  1. The proposed command arrives on stdin as JSON. The hook reads it before anything executes.
  2. Exit 2 is the block, and stderr is what the agent reads — see the exit-code table below.
  3. The message names the alternative. A bare "not allowed" costs three turns of guessing.
  4. Deliberately carved out: --force-with-lease is the safe kind, and a guard that blocks the safe path teaches people to disable it.

2. Format after every write, so code style stops being a conversation:

#!/usr/bin/env bash
# .claude/hooks/format.sh — PostToolUse, matcher: Write|Edit
set -euo pipefail
FILE=$(jq -r '.tool_input.file_path // empty')
[ -z "$FILE" ] && exit 0
[ -f "$FILE" ] || exit 0

case "$FILE" in
  *.ts|*.tsx|*.js|*.jsx|*.astro|*.json|*.css|*.md)
    ./node_modules/.bin/prettier --write "$FILE" >/dev/null 2>&1 || true ;;
esac
exit 0

Note || true. A formatter failing must never block a legitimate edit — this hook is a convenience, not a guard, and getting that distinction wrong makes your setup infuriating.

3. Guard destructive filesystem operations by showing the blast radius first:

#!/usr/bin/env bash
# .claude/hooks/destructive-guard.sh — PreToolUse, matcher: Bash
set -euo pipefail
CMD=$(jq -r '.tool_input.command // empty')

case "$CMD" in
  *"rm -rf"*|*"rm -r "*|*"find"*-delete*|*"truncate"*)
    TARGET=$(sed -E 's/.*rm -r[f]? +//; s/ .*//' <<<"$CMD")
    COUNT=$(find $TARGET 2>/dev/null | wc -l | tr -d ' ')
    echo "BLOCKED: would remove $COUNT path(s) under '$TARGET'." >&2
    echo "If that count is right, re-run with DESTRUCTIVE_OK=1 in the env." >&2
    [ "${DESTRUCTIVE_OK:-}" = "1" ] && exit 0
    exit 2 ;;
esac
exit 0

4. Inject the standing rules every session, so nothing depends on the agent choosing to read a file:

#!/usr/bin/env bash
# .claude/hooks/session-start.sh — SessionStart
cat <<'RULES'
NON-NEGOTIABLE:
- Verify before claiming done. Run the flow; read the output; show the receipt.
- Commit to main only. Never branch. No PRs unless asked.
- Deliver the literal ask. No silent scope changes.
- Churn the task list. Don't defer a doable task.
RULES

# Which project is this? State it, don't infer it from the folder name.
if [ -f .claude/COMPANY ]; then
  echo "COMPANY: $(cat .claude/COMPANY)"
else
  echo "COMPANY: unknown — ask before any branded or customer-facing work."
fi

And the wiring that makes all four live:

// .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [
        { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/git-policy.sh" },
        { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/destructive-guard.sh" }
      ]}
    ],
    "PostToolUse": [
      { "matcher": "Write|Edit", "hooks": [
        { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh" }
      ]}
    ],
    "SessionStart": [
      { "hooks": [
        { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" }
      ]}
    ]
  }
}

Exit codes are the whole API

Almost every broken hook I've written was a misunderstanding of this table, so it's worth committing to memory:

Exit codeEffectstderr goes toUse it for
0Allow — tool call proceedsNowhere (ignored)The normal path
2Block — tool call never runsThe agent, as feedbackPolicy violations
other non-zeroNon-blocking errorSurfaced as a warningHook itself is broken

The critical detail: on exit 2, your stderr is what the agent reads. That's your one chance to redirect it, which is why every block message above names the alternative. "BLOCKED: not allowed" makes an agent guess for three turns. "BLOCKED: branch creation. Commit straight to main." makes it do the right thing immediately.

Test your hooks before you trust them

A hook you haven't tested is a hook you don't have. They're plain scripts reading JSON on stdin, so they're trivially testable — and an untested guard tends to fail open, silently:

#!/usr/bin/env bash
# .claude/hooks/test.sh — assert each guard blocks and allows correctly
set -uo pipefail
H=.claude/hooks
pass=0; fail=0

check() { # check   
  local want=$1 hook=$2 cmd=$3
  printf '{"tool_input":{"command":%s}}' "$(jq -Rn --arg c "$cmd" '$c')" \
    | "$H/$hook" >/dev/null 2>&1
  local got=$?
  if [ "$got" = "$want" ]; then
    pass=$((pass+1)); printf '  ok    %-22s %s\n' "$hook" "$cmd"
  else
    fail=$((fail+1)); printf '  FAIL  %-22s %s (want %s got %s)\n' "$hook" "$cmd" "$want" "$got"
  fi
}

check 2 git-policy.sh       "git checkout -b feature/x"
check 2 git-policy.sh       "git switch -c hotfix"
check 2 git-policy.sh       "git push --force origin main"
check 0 git-policy.sh       "git push --force-with-lease origin main"
check 0 git-policy.sh       "git commit -m 'normal work'"
check 2 destructive-guard.sh "rm -rf dist"
check 0 destructive-guard.sh "ls -la"

echo "$pass passed, $fail failed"
[ "$fail" = 0 ]

That suite caught two genuine mistakes for me: the first version of the git guard blocked --force-with-lease (too strict, and lease-based pushes are the safe kind), and the destructive guard originally missed rm -r without the f. Both are exactly the kind of thing you discover at the worst possible moment otherwise.

Hooks are one layer of a larger harness — they sit alongside context architecture, terse reporting and cheap watchers in the full setup I actually run.

Where hooks are the wrong tool

Not everything belongs in a hook, and over-hooking makes an unusable setup.

Hooks are for bright-line, mechanically-detectable rules: this command never runs, this file always gets formatted, this fact is always injected. They're bad at judgement. "Write good copy", "match the surrounding style", "don't over-engineer this" cannot be pattern-matched, and trying produces a guard that blocks legitimate work while missing the actual problem.

The split I use:

Where does this rule belong?
hook it

Mechanical, bright-line, expensive if violated once.

Branch creation, destructive ops, formatting, session facts.

prompt it

Judgement, taste, style, tone.

Voice, architecture preferences, when to ask vs decide.

verify it

Outcomes you can observe.

Does the flow work, does the page render, is it deployed.

Most rules that keep getting broken are in the wrong column. "Verify before claiming done" spent months as a prompt rule and kept failing; it works now because it's a script the harness runs.

The catch: a hook you can't see is a mystery

The real cost of hooks is confusion when they fire and you've forgotten they exist. A blocked command with a vague message sends the agent — and you — off debugging a problem that is actually a policy working as designed.

Two rules that keep them maintainable:

Make the message say what to do instead. Not "BLOCKED: not allowed", but "BLOCKED: branch creation. Commit straight to main." The agent needs the alternative in the same breath as the refusal, or it'll spend three turns guessing.

Keep them few and legible. A handful of hooks you can recite beats twenty you can't. If you can't remember what a hook does, you'll misdiagnose the next time it fires.

The verdict

Stop trying to prompt your way to reliability on things that must not fail. A prompt is a probability, and probabilities lose over a long enough session. Move the bright-line rules into the harness where they execute, keep judgement in the prompt where it belongs, and put outcomes behind a check you can observe.

Design the error out. Then a false positive is a ten-second annoyance instead of a Monday-morning discovery.

My hook blocked me from writing about my hook. Working as intended. Do not @ me.

Want deterministic guardrails wired into your AI workflow? Get in touch, or browse the work first.

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.