Noddle Deck
All posts

Agent Engineering series

Hooks: Deterministic Guardrails for Non-Deterministic Agents

Noddle Deck team14 min read
hooksguardrailsautomation

You can put "never run rm -rf outside the project directory" in a system prompt, a skill, a CLAUDE.md file, three different places at once — and it will hold almost every time. Almost. A model is a probabilistic system: it weighs the instruction against everything else in context and produces the most likely next action, and the most likely action is usually the safe one. Usually isn't the bar you want for a destructive command, a production credential, or a merge to main.

A hook is how you close that gap. It's ordinary code — a shell script, a Python function, anything that can read stdin and set an exit code — that the agent's runtime invokes at a fixed point in its lifecycle, outside the model's reasoning entirely. The model never gets a vote on whether a hook runs. It just discovers, after the fact, what the hook decided.

Key takeaways

  • A hook is deterministic code triggered by a lifecycle event — before/after a tool call, on prompt submit, at session start or stop — not a request the model can decline or forget to make.
  • Hooks receive event context as JSON on stdin and communicate back via exit code: 0 means allow, 2 means block and feed stderr to the model as the reason, other codes are treated as a non-blocking error.
  • Hooks are for enforcement, not judgment. Use them for rules with a yes/no answer a script can compute — never for anything that needs the model's understanding of intent.
  • The failure modes run in both directions: a hook that duplicates the model's job wastes the guarantee; a hook that's slow or interactive stalls the whole loop it was supposed to protect.
  • PreToolUse has to be synchronous — it's a gate, not an observer. PostToolUse and Stop can afford to offload heavier work, because nothing downstream is waiting on their answer.
  • When multiple hooks share a matcher, they run in the order your config lists them. Put the fast, cheap gate first so an expensive check only runs once the free one has already passed.

Rules you ask for vs. rules you enforce#

Every instruction you give an agent through a prompt, a skill, or a CLAUDE.md file is a request. It goes into context alongside everything else, and the model does its best to honor it — but "its best" is still a probability distribution over possible next actions, not a guarantee. Longer sessions, busier context windows, and instructions buried a few thousand tokens back all quietly lower the odds that a given rule actually gets applied at the one moment it mattered.

A hook doesn't ask. It sits at a specific point in the agent's loop — say, right before any tool call — and runs every single time that point is reached, with no dependence on whether the model remembered, noticed, or agreed. If the hook decides to block, the action doesn't happen. That's the entire value proposition in one sentence: a hook trades the model's judgment for a script's certainty, at exactly the points where certainty is worth more than flexibility.

Figure 3

Determinism spectrum

Determinism spectrum diagramProbabilisticDeterministicPrompt askSystem reminderSkill instructionsHooks (exit code)
A prompt-level ask is a request the model weighs against everything else in context. A hook is a checkpoint the loop cannot pass without running the code — the only point on this spectrum with a hard guarantee.

Where hooks sit in the loop#

An agent's basic loop is short: the model decides on an action, a tool executes it, the result comes back, and the model decides again. Hooks attach to the seams in that loop — the moments right before or after something happens — rather than to the model's reasoning itself.

Figure 1

Agent loop with hook interception points

Hook interception points diagramModeldecides what to do nextTool callruns a command or editResultoutput returns to the modelnext turnPromptSubmitPreToolUsePostToolUseStop
Four lifecycle events give a hook somewhere to attach: when the user submits a prompt, right before a tool runs, right after it returns, and when the session ends. None of them require the model's cooperation to fire.
  • PromptSubmit — fires the instant a user's message is submitted, before the model has even started reasoning about it. Useful for injecting context, rejecting a prompt outright, or logging what was asked.
  • PreToolUse — fires after the model has decided to call a tool but before that tool actually runs. This is the checkpoint for anything you want the power to veto: a dangerous shell command, a write to a path outside the repo, a call to an API you haven't approved yet.
  • PostToolUse — fires right after a tool returns, with access to both what was requested and what came back. Good for auto-formatting a file the model just edited, or validating that a command's output actually looks like success rather than an error the model might misread.
  • Stop / SessionStart — fire at the boundaries of a session. Useful for a final audit log entry, a cleanup step, or loading environment-specific context before the first prompt is even processed.

Every one of these events hands the hook a JSON payload on stdin — the tool name, its arguments, the session id, and whatever else is relevant to that event type. The hook reads it, does whatever deterministic check it was written to do, and communicates its verdict back the only way a shell process can: through its exit code.

Exit codes are the entire interface#

There's no rich callback API here, and that's deliberate — it keeps a hook as simple as a Unix filter. Exit 0 and the action proceeds. Exit 2 and the action is blocked, with anything the hook wrote to stderr fed back to the model as the reason it was blocked, so the model can actually adjust rather than just retry blindly. Any other non-zero exit is treated as a non-blocking error — something went wrong in the hook itself, not a deliberate veto — and gets logged without stopping the loop.

Figure 2

Block vs. allow flow

Block versus allow flow diagramHook runsPreToolUseexit 2exit 0Blockedstderr is fed back to themodel as the reason. Thetool call never executes.Allowedthe tool executes exactlyas the model requested,no change in behavior.
The same PreToolUse hook produces one of two outcomes depending on its exit code. Exit 2 blocks the tool call and hands the model a reason it can act on. Exit 0 lets the call through unchanged.
hooks/block_dangerous_bash.py
#!/usr/bin/env python3
import json
import re
import sys
payload = json.load(sys.stdin)
command = payload.get("tool_input", {}).get("command", "")
DANGEROUS = [
r"rm\s+-rf\s+/(?!\S)", # rm -rf / (not a subpath)
r"git\s+push\s+.*--force",
r":\(\)\s*\{\s*:\|:&\s*\}", # fork bomb
]
for pattern in DANGEROUS:
if re.search(pattern, command):
print(f"Blocked: command matches disallowed pattern: {pattern}", file=sys.stderr)
sys.exit(2)
sys.exit(0)

Wire it up by pointing the PreToolUse hook at this script for the Bash tool specifically — a hook configuration usually lets you scope by tool name and even by a matcher on the tool's input, so you're not paying the cost of running this check against every single tool call, only the ones where it's relevant.

.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 hooks/block_dangerous_bash.py" }
]
}
]
}
}

What hooks are actually good for#

  • Auto-formatting after an edit. A PostToolUse hook on the file-edit tool that runs prettier or black against whatever file just changed means formatting is never something the model has to remember to do, and it's never something a reviewer has to flag in a PR comment either.
  • Blocking dangerous commands. The example above — exit non-zero, and a destructive shell command simply never executes, no matter how the model was talked into requesting it.
  • Audit logging. A PreToolUse hook that appends every tool call's arguments to a log file gives you a complete, tamper-resistant record of what an agent actually did in a session — useful for debugging and close to mandatory in a regulated environment where "the agent decided to do X" needs to be provable after the fact, not just plausible.
  • Desktop or Slack notifications. A Stop hook that pings you when a long-running agent session finishes is a five-line script that saves you from polling a terminal.

Hooks compose with skills and commands, they don't replace them

A skill can describe the review checklist for a migration; a hook can guarantee that no migration file gets edited without a linter running afterward. They're solving different halves of the same problem — the skill supplies judgment, the hook supplies a guarantee — and most mature setups end up using both rather than picking one.

Anti-patterns to avoid#

  • Doing the model's job in a hook. If a check genuinely requires understanding intent — "does this migration make semantic sense given the ticket it's linked to" — that's not a hook's job. Hooks are for questions with a mechanical yes/no answer: does this file match a pattern, does this command contain a banned substring, is this path inside the repo. The moment a hook needs to "understand" something, it's the wrong tool.
  • Slow hooks on hot paths. A PreToolUse hook runs synchronously, in the critical path, before the tool call it's gating. A hook that shells out to a slow network call on every single tool invocation turns a snappy interactive loop into one with a multi-second stall on every step — measure your hook's latency the same way you'd measure a middleware's latency in a request path, because that's exactly what it is.
  • Hooks that wait on interactive input. A hook that blocks on a prompt for human confirmation can deadlock an unattended or CI-driven agent session that has nobody watching to answer it. If a step genuinely needs a human in the loop, surface that through the agent's normal approval flow, not a hook that silently hangs.
  • Silent non-zero exits. Exiting 2 with nothing written to stderr blocks the action but gives the model no information to act on — it'll likely just retry the same blocked action, or a slightly different one that trips the same rule. Always explain the block in the text the model receives back.

A hook that's wrong is wrong every time, not just sometimes

The same property that makes hooks powerful — total determinism — makes a bug in one costly. A flawed prompt instruction fails gracefully some fraction of the time. A hook with a bug in its regex either blocks things it shouldn't or lets through exactly the thing it was written to stop, consistently, until someone notices and fixes it. Test hook logic the way you'd test any code that gates a production action, because that's what it is.

Worked example: blocking a force-push, watching the model recover#

The block/allow diagram above is easy to agree with in the abstract. What it actually looks like inside a session is more useful to see once — including the part where the model gets blocked, reads why, and tries something different without a human stepping in.

The full hook#

Extend the pattern-matching hook from earlier to cover a force-push specifically, with a clearer message than a bare regex match would give the model to work with:

hooks/block_dangerous_bash.py
#!/usr/bin/env python3
import json
import re
import sys
payload = json.load(sys.stdin)
command = payload.get("tool_input", {}).get("command", "")
RULES = [
(
r"git\s+push\s+.*--force(?!-with-lease)",
"force-push without --force-with-lease can silently drop a "
"teammate's commits. Use 'git push --force-with-lease' instead — "
"it fails safely if the remote has commits you haven't seen.",
),
(
r"rm\s+-rf\s+/(?!\S)",
"rm -rf / (or an unquoted path that could expand to it) is almost "
"never intentional. Target a specific directory instead.",
),
]
for pattern, reason in RULES:
if re.search(pattern, command):
print(f"Blocked: {reason}", file=sys.stderr)
sys.exit(2)
sys.exit(0)
.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 hooks/block_dangerous_bash.py" }
]
}
]
}
}

The session transcript#

Here's a plausible transcript from a session where the model was asked to "sync this branch with main and push it" — note that the hook never talks to the model directly, it only ever talks through stdin/stdout/exit code, and everything the model "sees" is the text the runtime hands back to it after the hook has already run:

bash
Assistant: I'll rebase onto main and push the updated branch.
> Tool call: Bash("git rebase main && git push origin feature/seat-limits --force")
[PreToolUse hook: block_dangerous_bash.py]
exit code: 2
stderr: "Blocked: force-push without --force-with-lease can silently
drop a teammate's commits. Use 'git push --force-with-lease'
instead — it fails safely if the remote has commits you
haven't seen."
Tool result: blocked — see reason above.
Assistant: Understood — force-push without a lease could drop commits
a teammate pushed after I last fetched. I'll use --force-with-lease
instead, which fails instead of overwriting if that's happened.
> Tool call: Bash("git rebase main && git push origin feature/seat-limits --force-with-lease")
[PreToolUse hook: block_dangerous_bash.py]
exit code: 0
Tool result: Everything up-to-date. Pushed to feature/seat-limits.
Assistant: Done — rebased onto main and pushed with --force-with-lease.

Nothing here required the model to have been prompted in advance about --force-with-lease specifically. It corrected its own next action because the block reason was specific enough to act on — this is the payoff of writing a real explanation to stderr instead of a bare exit code, and it's the difference between a hook that stops one bad action and a hook that teaches the model not to try the same bad action again this session.

How this fails in practice#

A hook's determinism cuts both ways — when it's wrong, it's wrong on every single matching call, not just some of them. These are the shapes that show up most often.

The match is too broad and blocks legitimate work#

Every git push starts getting blocked, not just forced ones — a routine push to a feature branch nobody else touches now fails the same way a genuinely dangerous force-push would. The regex was written as r"git\s+push" without the --force qualifier, probably during a rushed first pass that got the dangerous case right and the safe case wrong. The fix is the same discipline you'd apply to any pattern match guarding production behavior: write test cases for the commands that should pass alongside the ones that should be blocked, and run both before trusting the hook in a shared config.

The hook no-ops because of an environment mismatch#

The hook works on your machine and silently does nothing on a teammate's — dangerous commands sail through with no block and no error either. python3 resolves to a different interpreter, or a missing version, or isn't on PATH at all in their shell, and depending on how the runtime treats a hook command it can't execute, the default can be "log an error and allow the action" rather than "block." A hook that fails open on a missing dependency is a hook that's only as good as everyone's local environment matching yours. Pin the interpreter explicitly, check for it in a project setup script, and — for anything security critical — verify what your specific runtime does when a hook command itself can't run, rather than assuming it fails closed.

The hook stalls the whole loop#

Every tool call in a session starts taking a few extra seconds, and it's not obvious why until someone profiles it: a PreToolUse hook is making a synchronous network call to a compliance service on every single tool invocation, including the overwhelming majority that were always going to be allowed anyway. The hook itself is correct — it's just paying a network round-trip on the hot path of every step in the loop. Cache the allow decision where the inputs repeat, narrow the matcher so the hook only fires for the tool calls it actually needs to gate (destructive commands, not every Bash call), or move the check to a less frequent event if it doesn't need to block in real time.

Hook ordering produces a surprising result#

A PostToolUse auto-formatter and a PostToolUse diff-size validator are both registered on the same file-edit tool, and depending on which one the config lists first, the validator sometimes measures the diff before formatting ran and sometimes after — producing a different verdict for what is, from the model's point of view, the same edit. Multiple hooks under one matcher run in the array order your config lists them, and that order is easy to lose track of once a config file has accumulated a handful of entries from different points in time. Keep hook chains short, name each hook's command descriptively enough that the order reads as intentional in a review, and put anything that can block ahead of anything that only observes or transforms.

Design decisions: sync vs. async, timeout budgets, chain order#

Sync vs. async#

PreToolUse has no choice but to be synchronous — its entire job is to answer "does this action proceed" before the action happens, so the loop has to wait for it. PostToolUse and Stop don't carry that constraint, because nothing downstream depends on their answer — a formatter running after an edit, or an audit log write at session end, can be fired off and left to finish on its own time without the agent sitting idle waiting for it. Treat the lifecycle event as the deciding factor: if the hook's answer changes what happens next, it has to block and be fast; if the hook's job is bookkeeping that nothing else reads before the session moves on, let it run asynchronously and stop budgeting its latency into the interactive loop at all.

Timeout budgets#

Every hook needs an explicit latency budget, not just an implicit hope that it'll be fast. A regex check against a string on stdin should complete in single-digit milliseconds — if it's taking longer, something is wrong with the hook, not the budget. Anything that shells out to a network call needs a real ceiling, a couple of seconds at most for something in the interactive path, and a decision about what happens when that ceiling is hit: most runtimes treat a timed-out hook as a non-blocking error, which means the action proceeds by default. That's an acceptable default for a cosmetic hook — a formatter that never got to run just means the file stays unformatted. It's not acceptable for a security-critical one, where a timeout should read as a block, not a pass. Know which behavior your runtime gives you before you rely on a hook for enforcement rather than convenience.

Chain ordering#

When more than one hook shares a matcher, put the cheapest, fastest, most likely-to-reject check first. A hook that rejects 90% of the traffic a slower downstream hook would otherwise have had to process is doing real work just by sitting in front of it — the expensive check only runs on the 10% that already cleared the cheap one. This is the same ordering intuition you'd apply to middleware in a request pipeline, and it's worth writing down explicitly in a comment near the hook config, because array order in a JSON file carries no explanation on its own, and the next person to add a third hook to the chain needs to know where it belongs and why.

Hooks and Noddle Deck#

Persona packs in the Noddle Deck marketplace pair skills and commands with the guardrails that make sense for a given role — and where a check genuinely needs a hard guarantee rather than a polite instruction, that's exactly where a hook belongs alongside them. If you're building your own pack, or just hardening a workflow for your team, the pattern in this post — read stdin, make one deterministic decision, exit 0 or 2 — is the whole shape a hook needs to have.

bash
noddle-deck pack install developer

Installed skills and commands land in ~/.claude/skills/noddle-deck-{pack}/ and ~/.claude/commands/noddle-deck-{pack}/. Layer a hook on top of the tool calls those commands and skills make, and you've got the full toolkit this series covers: something you invoke, something the agent triggers on its own, and something that can't be talked out of doing its job either way.

Put this into practice

Noddle Deck packs ship curated skills and slash-commands for your role — install one and see this in action.

Browse persona packs