{"slug": "less-prompts-more-guardrails", "title": "Less prompts, more guardrails", "summary": "Yasyf Mohamedali published a declarative hooks framework called captain-hook that lets AI coding agents use a Python DSL to block, substitute, or warn on tool calls such as `rm` commands, rather than relying on advisory prompts or mute sandboxes. The framework builds on Claude Code's PreToolUse hooks, returning hints and substitutions into the model's context to steer its next action, and extends beyond bash commands to intercepting code edits. The author argues hooks are the only mechanism of the three that closes the loop by feeding guidance back to the agent.", "body_md": "# Less prompts, more guardrails\n\n+ iteratively build a harness as you go\n\n TL;DR I present the motivation for a declarative hooks framework for agent harnesses, with a DSL that agents use to constantly improve their harness. Check it out live at [captain-hook](https://yasyf.github.io/captain-hook/). \n\n## Please don’t `rm`…\n\nOne of the unfortunate externalities of prompt engineering working so damn well is we often forget to reach for other tools in our toolbox. The canonical example is the AGENTS.md with “do NOT run rm on any potentially empty variable”… only to watch codex `rm -rf \"$DEFINITELY_NOT_EMPTY/*\"`. This seems like a bit of a strawman but it’s [exactly what we saw back in July](https://x.com/thsottiaux/status/2077630111499882637).\n\nOf course, this is why the (oft-under-appreciated) hooks system exists. We’ll use Claude Code in our examples, since its hooks are the most robust and familiar, but this applies to any harness. Implementing a hook blocking any command with `rm` is pretty simple:\n\n```\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Bash\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"if\": \"Bash(rm *)\",\n            \"command\": \"echo 'Blocked: rm is not allowed' >&2; exit 2\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThis covers our asses, but it’s restrictive and adds little beyond simple sandboxing. What we’d really like is a DSL that adds some sugar on top of this raw JSON and bash-fu. Maybe something like:\n\n``` python\nfrom hooks import on, Event, Tool\n\n@on(Event.PreToolUse, only_if=[Tool(\"Bash\")])\ndef check_command(evt):\n    if \"rm\" in evt.tool_input[\"command\"]:\n        return evt.block(\"rm is not allowed\")\n```\n\nAdmittedly, this looks like *more* work than the first option, but it unlocks some powerful things! For example, let’s add a command-parsing layer:\n\n``` python\n@on(Event.PreToolUse, only_if=[Command(\"rm\")])\ndef check_command(evt):\n    return evt.block(\"rm is not allowed\")\n```\n\nwhich with a bit of sugar becomes:\n\n``` python\nfrom hooks import block_command\n\nblock_command(\n    [\"rm\"],\n    reason=\"rm is banned in this repo\",\n    hint=\"Use `trash` instead if on macOS\",\n)\n```\n\nor if we wanted to make that hint actionable:\n\n``` python\n@on(Event.PreToolUse, only_if=[Command(\"rm\")])\ndef check_command(evt):\n    return evt.cmd.sub(\"rm\", \"trash\") if evt.ctx.darwin else evt.block(\"rm is not allowed\")\n```\n\nwe can get really fancy now, if we want:\n\n``` python\n@on(Event.PreToolUse, only_if=[Command(\"rm\")])\ndef check_command(evt):\n    if len(evt.cmd.call(\"rm\").target()) > 10:\n      return evt.warn(\"You're deleting more than 10 files, FYI\")\nBLOCKED: rm is banned in this repo. Use `trash` instead if on macOS.BLOCKED: rm is banned in this repo. Use `trash` instead if on macOS.\n```\n\nplain rm call: blocked, with a hint for Claude\n\n## Intercepting edits\n\nOk, so we can block commands. Whoop-de-doo. Like I said, fancy sandboxing.\n\nWell, not quite. A prompt is advisory: the model is free to ignore it, and empirically it does. A sandbox is a mute wall: the command dies, the agent learns nothing, and it retries blind. A hook is the only one of the three that closes the loop — the `hint`, the `sub`, the `warn` all land back in the model’s context, *steering* the next action instead of just denying this one.\n\n```\nrm -rf \"$BUILD_DIR/*\"\nrm -rf \"$BUILD_DIR/*\"\nrm -rf \"$BUILD_DIR/*\"\n```\n\nHowever, we can build on this DSL to start intercepting more than bash commands. Say, edits.\n\nMost languages have community-standardized linting tools (`eslint`, `black`, etc) which mostly act by parsing the AST, looking for forbidden patterns, then reporting back. In theory, this should make it quite easy to add custom linting rules… in reality not so much. Here’s a fun challenge:\n\nWith a hooks framework, this becomes stupid simple. If we were writing everything by hand, we would do something like:\n\n``` php\nimport ast\n\ndef added_prints(tree: ast.AST) -> set[int]:\n      return {\n          node.lineno\n          for node in ast.walk(tree)\n          if isinstance(node, ast.Call)\n          and isinstance(node.func, ast.Name)\n          and node.func.id == \"print\"\n      }\n\n@on(Event.Edit)\ndef prevent_prints(evt):\n    if (lines := added_prints(ast.parse(evt.file.open()))):\n      evt.block(f\"Forbidden prints found on lines: {lines}\")\n```\n\nif we move the AST parsing into the framework and add a shortcut called `lint`:\n\n``` python\nfrom hooks import lint\n\nlint(\n    added_prints,\n    message=\"This edit added a print() call: {violations}\",\n)\n```\n\nWe use the fantastic [`ast-grep`](https://github.com/ast-grep/ast-grep) under the hood to parse across any language, so we can actually skip defining `added_prints` altogether and go after the JS offenders while we’re at it:\n\n```\nlint(\n    pattern=\"console.log($$$)\",\n    message=\"This edit added a console.log() call: {violations}\",\n    lang=\"ts\",\n)\n```\n\nThis really works! You can go one step further and just fix it automatically:\n\n``` python\nfrom hooks import rewrite_code\n\nrewrite_code(\"print($MSG)\", \"logger.info($MSG)\")\nPreToolUse ❯ rewrite_code(\"print($MSG)\", \"logger.info($MSG)\")\n→ rewrote apply_patch.py:2, apply_patch.py:5\n```\n\nAnother great use case for this hook is to prevent the LLM from type-widening when it gets frustrated. However, this one requires some judgment. Luckily, we can bake (small) LLM calls into our framework pretty easily:\n\n``` python\n@on(Event.Edit)\ndef block_type_widening(evt):\n    if evt.edit.introduces(\"...\") and not evt.llm(\"is this a throwaway file or test?\"):\n      evt.block(\"Do not use Any as an escape hatch\")\n```\n\nor with a bit of sugar:\n\n``` python\n  from hooks import Introduced, llm_nudge\n\n  llm_nudge(\n      \"Fire if the code is non-trivial (not a test or throwaway), and there is a stronger type that can be used.\",\n      message=\"Do not use Any as an escape hatch: {reasoning}.\",\n      contexts=[Introduced(pattern=\"def $F($$$) -> Any:\")],\n      only_if=[Tool(\"Edit\")],\n  )\n```\n\nChanging `Introduced` to `Matches` would prevent the `Any` s from getting introduced in the first place.\n\nstore.py\n\nwidens the return type\n\n``` php\n1-def load(path: str) -> Config:1+def load(path: str) -> Any:2     raw = read_text(path)3     return Config.parse(raw)\n```\n\nnudge1 match\n\nstore.py\n\nnudge\n1 match\n\ncache.py\n\nhandles cache misses\n\n``` php\n1 STORE: dict[str, Config] = {}2 3 def fetch(key: str) -> Any:4-    return STORE.get(key)4+    hit = STORE.get(key)5+    if hit is None:6+        raise KeyError(key)7+    return hit\n```\n\nsilentno match\n\ncache.py\n\nsilent\nno match\n\n```\nllm_nudge(\n    message=\"Do not use Any as an escape hatch: {reasoning}.\",\n    contexts=[Introduced(pattern=\"def $F($$$) -> Any:\")],\n)\nstore.py:1 → Do not use Any as an escape hatch: load returns a Config, so the annotation can stay Config.\ncache.py → silent\n```\n\n## Beyond tool calls\n\n### Stop gates\n\nAnother classic CLAUDE.md decree is some variation on “review your code when done implementing”. You could stick that in a Stop hook:\n\n```\n{\n  \"hooks\": {\n    \"Stop\": [\n      {\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"echo 'Did you review your code against STYLEGUIDE.md?' >&2; exit 2\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nYou might even be using `/loop`, which is basically a Stop hook that says “go look at the original prompt and see if you’re actually done”. These both hint at something powerful: steering Claude at the turn boundary. However, like before, plain-JSON hooks leave you pretty limited. With a DSL, however, the sky is the limit!\n\n``` python\n@on(Event.Stop)\ndef ensure_code_reviewed(evt):\n    if not evt.transcript.read_file(\"STYLEGUIDE.md\"):\n      return evt.block(\"Consult the styleguide and review your code before ending your turn.\")\n    for f in evt.ctx.changed_files:\n      if evt.t.last_read(f) < evt.t.last_write(f):\n        return evt.block(f\"You must review {f.path}.\")\n```\n\n`@on(Event.Stop)`\nturn\n\ngate\n\nblock\n- STYLEGUIDE.mdread\n- hooks.pyreviewed\n- tests.pyunreviewed\n\n```\nYou must review tests.py.You must review tests.py.\n```\n\nblock: You must review tests.py.\n\n### Approvals\n\n`Event.PermissionRequest` + a small LLM lets you re-implement Claude Code’s auto mode with your own criteria *(the LLM here also gets a copy of `claude auto-mode defaults`)*. For example, if we want our subagents to have full read-only autonomy, but apply more scrutiny to our main agent:\n\n``` python\nfrom hooks import FromSubagent, llm_approve\n\nllm_approve(\n    \"safe teammate commands\",\n    rubric=\"Read-only commands and test runs are safe.\",\n    only_if=[FromSubagent()],\n)\n```\n\nor, a more fun `--dangerously-skip-permissions`:\n\n``` python\nfrom hooks import LambdaCondition, approve\nfrom datetime import datetime\n\napprove(\n    \"only during daylight hours\",\n    only_if=[LambdaCondition(lambda evt: datetime.now().hour < 20)],\n)\n```\n\nllm_approve(\"safe teammate commands\")\n\nrubric: Read-only commands and test runs are safe.\n\nallowruns without asking\n\nls -la\n\nallow\nruns without asking\n\n``` bash\n$ ls -la\nhook: llm_approve(\"safe teammate commands\")\n→ allow · read-only, rubric matches\n```\n\napprove(\"only during daylight hours\")\n\nonly_if: `LambdaCondition(lambda evt: datetime.now().hour < 20)`\n\n4:00 PM\n\nbefore 8:00 PM: allow · from 8:00 PM: ask\n\nallowruns without asking\n\n4:00 PM\n\nallow\nruns without asking\n\n```\nhook: approve(\"only during daylight hours\")\n  datetime.now().hour < 20 → True\n→ allow · 4:00 PM, nothing to answer\n```\n\n## From responding to steering\n\nCorrecting the LLM helps, but you’ll get more out of your mini-harness (I said the H-word!) by steering *proactively*, not *responsively*. This also helps a lot with token costs and overall iteration speed.\n\nA great example that gets triggered for almost every agent at Aneta is the notion of iterative narrowing. By default, when the agent is running a test that fails, it will keep using that test to check its fix. Logical, until that test is a giant integration test that takes 10 minutes to run. So, we give it a skill called `iterative-narrowing`, and steer it towards that skill whenever we detect it thrashing. A smaller LLM can do the “is it thrashing” classification pretty well, but to avoid slowing down every tool call, we use some cheap NLP signals as a high-pass filter (with a veto clause to bail early if the agent is already doing the right thing).\n\n``` python\nfrom hooks import Clause, NlpSignal, Phrase, Signals, UsedSkill\n\nllm_nudge(\n    \"Decide whether the agent is iterating blind (thrashing)\"\n    \" or appropriately narrowing/investigating\",\n    message=\"You are thrashing: {reasoning}. Narrow the failing test with /iterative-narrowing before running it again.\",\n    signals=Signals(\n        [\n            NlpSignal(\n                clauses=[\n                    Clause(verb=Phrase(\"try\", \"rerun\"), adj=Phrase(\"again\")),\n                    Clause(verb=Phrase.expand(\"retry\", pos=\"v\"), tense=\"prospective\"),\n                ],\n                weight=2,\n            ),\n            NlpSignal(\n                clauses=[\n                    Clause(noun=Phrase(\"test\", \"suite\"), verb=Phrase(\"run\", \"rerun\"))\n                ],\n                weight=1,\n            ),\n        ],\n        threshold=3,\n        window=10,\n        scope=\"window\",\n        vetoes=[\n            NlpSignal(\n                clauses=[\n                    Clause(\n                        verb=Phrase(\"narrow\", \"bisect\"), noun=Phrase(\"test\", \"repro\")\n                    )\n                ],\n            )\n        ],\n    ),\n    skip_if=(UsedSkill(\"iterative-narrowing\"),),\n)\n```\n\nscore 2/3\n\nquiet\nunder threshold\n\n1. +2Let me try running it againtry/rerun + again\n\n```\nfilter 2/3 over the last 10 messages\nunder threshold\nno classifier callfilter 2/3 over the last 10 messages\nunder threshold\nno classifier call\n```\n\n## Tests!\n\nThe most annoying part of the settings.json hooks is that you’ve got absolutely no clue if it does what you want until you fire up a session and test it manually. Which is.. a lil scary when it’s your `rm` hook. It’s GitHub Actions yaml all over again. With a DSL, inline tests are simple to add; running them is as simple as `uvx capt-hook test`.\n\n```\nblock_command(\n    [\"rm\"],\n    reason=\"rm is banned in this repo\",\n    hint=\"Use `trash` instead if on macOS\",\n    tests={\n        Input(command='rm -rf \"$DEFINITELY_NOT_EMPTY/*\"'): Block(),\n        Input(command=\"trash old-builds/\"): Allow(),\n    },\n)\n```\n\nThis is a fairly obvious case but you can imagine how useful this gets as your hook complexity scales.\n\ninline tests\n\n2 tests · 0 failed\n\nblock_command([\"rm\"], reason=…, hint=…)\n\n``` bash\n$ uvx capt-hook test\n  PASS  no_rm:hook_8cf20307:Input(command='rm -rf \"$DEFINITELY_NOT_EMPTY/*\"')\n  PASS  no_rm:hook_8cf20307:Input(command='trash old-builds/')\n\n2 tests: 2 passed, 0 failed, 0 errors, 0 skipped$ uvx capt-hook test\n  PASS  no_rm:hook_8cf20307:Input(command='rm -rf \"$DEFINITELY_NOT_EMPTY/*\"')\n  PASS  no_rm:hook_8cf20307:Input(command='trash old-builds/')\n\n2 tests: 2 passed, 0 failed, 0 errors, 0 skipped\n```\n\n## Let the agent build the harness\n\nThe framework is great and all, but we don’t write code by hand these days. A good DSL is designed from the ground up to let agents write it. Our framework takes it one step further, using—you guessed it—hooks!\n\n```\ncaptain-hook · github.com/yasyf/app [watching] PR slots 1/2promote at 3 sessions across 2 days · max 2 open PRsWATCHING(building toward the bar)2█░░hooks.style:nudge_1: stop firing on its own output1/3 sessions · 0/2 days██░block pnpm install from the repo root2/3 sessions · 2/2 daysELIGIBLE(a PR opens next session)1███run the test suite before every commit3/3 sessions · 2/2 daysPR OPEN(awaiting your review)1███use uv instead of pip#12ACCEPTED(PR merged)1███use loguru instead of print#7reviewer idle · next scan at session end\n```\n\neligible rules wait for the next session and a free PR slot\n\nIf you haven’t figured it out by now, this not-so-hypothetical framework is completely real; substitute `hooks` for `captain_hook` in all the above code and it actually runs. At the end of each session, a `SessionEnd` hook kicks off an asynchronous review of your interactions with the agent, keeping track of any corrections or steering you had to do. It stores this in a local SQLite database, deduped by semantic similarity. When it determines a correction is more than a one-off (the default bar: the same deduped correction across 3 distinct sessions spanning at least 2 days, every knob an env var), it [opens a PR with a hook](https://github.com/yasyf/captain-hook/pull/12/changes). All you have to do is merge, and your harness is now a little better tuned for your workflow. Repeat over weeks and months, and you end up with a lightweight, hyper-tuned harness layered over Claude Code that took you zero effort to build.\n\nsession 1 · day 1 · you say: don't run pnpm install from the repo root\n\n```\nblock_command(\n    [\"pnpm\", \"install\"],\n    hint=\"cd into the workspace first\",\n)SessionEnd fires detached · your prompt is already back\nno rule yet · the reviewer is still counting\n```\n\n[captain-hook#12](https://github.com/yasyf/captain-hook/pull/12)\n\nTry it out yourself:", "url": "https://wpnews.pro/news/less-prompts-more-guardrails", "canonical_source": "https://yasyf.com/writing/less-prompts-more-guardrails/", "published_at": "2026-09-09 09:59:00+00:00", "updated_at": "2026-10-05 21:47:02.883300+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-safety"], "entities": ["captain-hook", "Yasyf Mohamedali", "Claude Code", "Codex", "eslint", "black"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/less-prompts-more-guardrails", "markdown": "https://wpnews.pro/news/less-prompts-more-guardrails.md", "text": "https://wpnews.pro/news/less-prompts-more-guardrails.txt", "jsonld": "https://wpnews.pro/news/less-prompts-more-guardrails.jsonld"}}