{"slug": "coding-agents-a-silent-hook-babysits-a-loud-hook-teaches", "title": "Coding agents: A silent hook babysits, a loud hook teaches", "summary": "A developer distinguishes between silent hooks that fix mechanical issues and loud hooks that teach agents by rejecting incorrect actions. The loud reject pattern is demonstrated with a Flyway migration naming hook that blocks invalid filenames and explains the rule, forcing the agent to learn the correct naming convention. The developer provides a decision table for choosing between silent fix, loud fix, and loud reject based on whether the fix is mechanical or requires agent knowledge.", "body_md": "Hi friends\n\nThird 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.\n\nA 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.\n\nBut hooks come in two flavors:\n\nSilent 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.\n\nOur migrations use Flyway, and the file name IS the API:\n\n```\nV012__Billing_AddInvoiceIndex.sql\n```\n\nGet 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`\n\n.\n\nCould 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:\n\n``` bash\n#!/usr/bin/env bash\n# PreToolUse hook on Write|Edit: enforce Flyway migration naming.\nset -euo pipefail\n\ninput=$(cat)\nfile=$(python3 -c 'import json,sys; print(json.load(sys.stdin).get(\"tool_input\",{}).get(\"file_path\",\"\"))' <<<\"$input\")\n\n[[ \"$file\" != */db/migrations/* ]] && exit 0\n\nname=$(basename -- \"$file\")\nif [[ ! \"$name\" =~ ^[VB][0-9]{3}__[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9]+\\.sql$ ]]; then\n  {\n    echo \"Blocked: '$name' is not a valid Flyway migration name.\"\n    echo \"Expected: V<3-digit-version>__<SchemaName>_<Description>.sql\"\n    echo \"Example:  V012__Billing_AddInvoiceIndex.sql\"\n    echo \"(B prefix for baseline migrations. Check existing files for the next version number.)\"\n  } >&2\n  exit 2\nfi\nexit 0\n```\n\nWired up in `settings.json`\n\n:\n\n```\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Write|Edit\",\n        \"hooks\": [{ \"type\": \"command\", \"command\": \"$CLAUDE_PROJECT_DIR/.claude/hooks/migration-name.sh\" }]\n      }\n    ]\n  }\n}\n```\n\nWhat 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__...`\n\n, and names every migration correctly for the rest of the session. The violation taught it.\n\n\"Fail loudly\" is a choice of three patterns, and the decision fits in a table:\n\n| Pattern | Mechanics (Claude Code) | Use when |\n|---|---|---|\nSilent fix |\napply fix, `exit 0`\n|\nfix is mechanical and always correct (formatting) |\nLoud fix |\napply fix, print what you fixed to stderr, `exit 2` (PostToolUse) |\nfix is safe to automate but the pattern should stop appearing |\nLoud reject |\nno fix, print the rule to stderr, `exit 2` (PreToolUse) |\nfix needs knowledge the hook doesn't have, or the action is dangerous |\n\nExit codes, because this is where people get it wrong: `exit 0`\n\nallows, the agent sees nothing. `exit 2`\n\nfeeds 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.\n\nConverting a silent autofix into a **loud fix** is one extra branch:\n\n```\n# Before - agent never learns, hook mops up forever\neslint --fix \"$file\" >/dev/null 2>&1\nexit 0\n# After - same fix, and the agent stops making the mistake\nbefore=$(git hash-object \"$file\")\neslint --fix \"$file\" >/dev/null 2>&1\nafter=$(git hash-object \"$file\")\n\nif [[ \"$before\" != \"$after\" ]]; then\n  {\n    echo \"Auto-fixed import order in $file.\"\n    echo \"Rule: external imports first, then common/, then relative, sass last.\"\n    echo \"Write it in that order next time.\"\n  } >&2\n  exit 2\nfi\nexit 0\n```\n\nThat's the whole solution: detect whether you changed anything, and if you did, say so on stderr and exit 2.\n\nThe 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.\n\nThe rejection message is where most hooks fail. It needs four things:\n\nA hook that just says `error: invalid file`\n\nblocks but teaches nothing - the agent retries variations and burns tokens guessing. Write the stderr like a review comment from a good colleague.\n\nIs 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.\n\nFires once in a while: backstop doing its job. Fires repeatedly: a failing test against your docs. Three usual causes:\n\nOne line of bash turns the hook into telemetry - log before the `exit 2`\n\n:\n\n```\necho \"$(date +%F) $name\" >> \"$CLAUDE_PROJECT_DIR/.claude/hook-rejections.log\"\n```\n\nThe 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.\n\nOne sentence to keep: a silent hook babysits, a loud hook teaches. Babysit the whitespace, teach everything else.\n\nHope that helped!\n\nHash", "url": "https://wpnews.pro/news/coding-agents-a-silent-hook-babysits-a-loud-hook-teaches", "canonical_source": "https://dev.to/hash01/ai-skills-a-silent-hook-babysits-a-loud-hook-teaches-1b5h", "published_at": "2026-07-28 17:48:00+00:00", "updated_at": "2026-07-28 18:06:01.717312+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Flyway", "Claude Code"], "alternates": {"html": "https://wpnews.pro/news/coding-agents-a-silent-hook-babysits-a-loud-hook-teaches", "markdown": "https://wpnews.pro/news/coding-agents-a-silent-hook-babysits-a-loud-hook-teaches.md", "text": "https://wpnews.pro/news/coding-agents-a-silent-hook-babysits-a-loud-hook-teaches.txt", "jsonld": "https://wpnews.pro/news/coding-agents-a-silent-hook-babysits-a-loud-hook-teaches.jsonld"}}