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

> Source: <https://dev.to/hash01/ai-skills-a-silent-hook-babysits-a-loud-hook-teaches-1b5h>
> Published: 2026-07-28 17:48:00+00:00

Hi friends

Third post in a series about setting up coding agents in a real codebase (part 1: [structuring CLAUDE.md, skills and agents](https://dev.to/hash01/how-to-structure-claudemd-skills-and-agents-2p7a), part 2: [skill descriptions](https://dev.to/hash01/claude-skill-description-o9n)). This one is about hooks, and the difference between a hook that fixes and a hook that teaches.

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:

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.

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

```
V012__Billing_AddInvoiceIndex.sql
```

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:

``` bash
#!/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
```

Wired up in `settings.json`

:

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

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.

"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
# 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
```

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 rejection message is where most hooks fail. It needs four things:

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.

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:

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"
```

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.

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

Hope that helped!

Hash
