DEV Community

Hamid Shoja
Hamid Shoja

Posted on

Coding agents: A silent hook babysits, a loud hook teaches

Hi friends

Third post in a series about setting up coding agents in a real codebase (part 1: structuring CLAUDE.md, skills and agents, part 2: skill descriptions). This one is about hooks, and the difference between a hook that fixes and a hook that teaches.

Two flavors of hook

A hook is code that runs on the agent's actions - the surface for rules that must never be skipped. Instructions can be rationalized away ("it's just a filename, close enough"); a hook can't.

But hooks come in two flavors:

  • A silent-fix hook repairs the output automatically. The agent never sees an error, so it keeps generating the wrong pattern forever.
  • A fail-loudly hook blocks and returns an error. The message lands in the agent's context - one failure, one lesson.

Silent is fine when the fix is mechanical and always correct: we run prettier from a hook after every turn, the agent never learns to format, nobody cares. The interesting case is when the fix needs knowledge the hook doesn't have. Then loud is mandatory.

The Postgres migration hook

Our migrations use Flyway, and the file name IS the API:

V012__Billing_AddInvoiceIndex.sql
Enter fullscreen mode Exit fullscreen mode

Get it wrong and nothing crashes on your machine - Flyway just skips the file or orders it wrong, and you find out in a deploy. It's exactly the kind of rule agents violate: the SQL is perfect, the filename is add_invoice_index.sql.

Could a hook silently rename it? No. A rename needs the next version number, the right schema, a description - knowledge that lives on the agent's side. A silent fix here isn't just pedagogically worse, it's less correct. So the hook's job is to refuse, and say why:

#!/usr/bin/env bash
# PreToolUse hook on Write|Edit: enforce Flyway migration naming.
set -euo pipefail

input=$(cat)
file=$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("file_path",""))' <<<"$input")

[[ "$file" != */db/migrations/* ]] && exit 0

name=$(basename -- "$file")
if [[ ! "$name" =~ ^[VB][0-9]{3}__[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9]+\.sql$ ]]; then
  {
    echo "Blocked: '$name' is not a valid Flyway migration name."
    echo "Expected: V<3-digit-version>__<SchemaName>_<Description>.sql"
    echo "Example:  V012__Billing_AddInvoiceIndex.sql"
    echo "(B prefix for baseline migrations. Check existing files for the next version number.)"
  } >&2
  exit 2
fi
exit 0
Enter fullscreen mode Exit fullscreen mode

Wired up in settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/migration-name.sh" }]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

What happens next is the whole point: the write is blocked, the agent reads the rule, checks existing files for the next version, renames to V013__..., and names every migration correctly for the rest of the session. The violation taught it.

The fix, precisely

"Fail loudly" is a choice of three patterns, and the decision fits in a table:

Pattern Mechanics (Claude Code) Use when
Silent fix apply fix, exit 0 fix is mechanical and always correct (formatting)
Loud fix apply fix, print what you fixed to stderr, exit 2 (PostToolUse) fix is safe to automate but the pattern should stop appearing
Loud reject no fix, print the rule to stderr, exit 2 (PreToolUse) fix needs knowledge the hook doesn't have, or the action is dangerous

Exit codes, because this is where people get it wrong: exit 0 allows, the agent sees nothing. exit 2 feeds stderr back to the agent - on PreToolUse it also cancels the action; on PostToolUse the action already happened, so it just delivers the lesson. Any other exit code shows stderr to the human only: the agent learns nothing, the worst option for rules.

Converting a silent autofix into a loud fix is one extra branch:

# Before - agent never learns, hook mops up forever
eslint --fix "$file" >/dev/null 2>&1
exit 0
Enter fullscreen mode Exit fullscreen mode
# After - same fix, and the agent stops making the mistake
before=$(git hash-object "$file")
eslint --fix "$file" >/dev/null 2>&1
after=$(git hash-object "$file")

if [[ "$before" != "$after" ]]; then
  {
    echo "Auto-fixed import order in $file."
    echo "Rule: external imports first, then common/, then relative, sass last."
    echo "Write it in that order next time."
  } >&2
  exit 2
fi
exit 0
Enter fullscreen mode Exit fullscreen mode

That's the whole solution: detect whether you changed anything, and if you did, say so on stderr and exit 2.

The migration hook above is the loud reject template: match the path, validate, refuse with the rule plus a valid example. Swap the regex and the message and you've got the same enforcement for branch names, commit formats, whatever your team keeps repeating in review.

The error message is a teaching surface

The rejection message is where most hooks fail. It needs four things:

  1. What was blocked - the exact filename, no ambiguity
  2. The rule - the format spec, not just "invalid name"
  3. A valid example - agents pattern-match; one example beats three paragraphs
  4. Where to look next - "check existing files for the next version number"

A hook that just says error: invalid file blocks but teaches nothing - the agent retries variations and burns tokens guessing. Write the stderr like a review comment from a good colleague.

A repeated rejection is a bug report against your docs

Is it OK that this loop repeats every session - violate, reject, correct, forget? No. The lesson dies with the context window, and a rejection that fires session after session means your instruction layer failed: docs and skills are prevention (they load before the action), the hook is the guarantee - and its firings are telemetry on how prevention is doing.

Fires once in a while: backstop doing its job. Fires repeatedly: a failing test against your docs. Three usual causes:

  • The rule isn't in any surface that loads before the action -> add it to the skill that owns the task (our migration rule belongs in the migrations skill, not just the hook).
  • The rule exists but the skill never triggers -> a routing bug, fix the skill description (previous post).
  • The rule is in CLAUDE.md but buried in a wall of text -> promote it, shorten it, or move the noise out.

One line of bash turns the hook into telemetry - log before the exit 2:

echo "$(date +%F) $name" >> "$CLAUDE_PROJECT_DIR/.claude/hook-rejections.log"
Enter fullscreen mode Exit fullscreen mode

The most-fired rules in that file are exactly the docs most worth fixing. So "hook or fix the skill?" is the wrong question - both, in that order of time: hook immediately, then let the repeat-firings tell you which doc fix pays for itself.

Loud or silent - the checklist

  • Fix is mechanical, always correct, and nobody cares if it repeats (formatting)? Silent fix.
  • Fix is safe to automate but you're tired of seeing the pattern? Loud fix.
  • Fix needs knowledge or judgment (naming, versioning, schema)? Loud reject.
  • Violation is expensive or irreversible downstream (prod deploy, data migration)? Loud reject, always.

One sentence to keep: a silent hook babysits, a loud hook teaches. Babysit the whitespace, teach everything else.

Hope that helped!
Hash

Top comments (0)