DEV Community

Pawel
Pawel

Posted on

I Replaced All My Hooks With Mods. Here's What Changed.

I had five settings hooks. A guard that blocked rm -rf. A logger that recorded every file edit. A notifier that pinged me when a long turn finished. An auto-approver for safe reads. And a context counter I duct-taped onto the status line.

Last weekend I migrated all five to Claude Code mods. The honest ledger: three got shorter, one turned out to be impossible as a hook in the first place, and one opened a hole I had not thought about.

Here is the before and after, with code.

A quick refresher: what hooks actually are

A settings hook is a shell command that Claude Code runs on a lifecycle event, passing JSON over stdin and stdout. Your script reads the event, and decides: let it through, block it, or tweak it. It is simple, it is battle-tested, and it runs in a subprocess that starts, does its job, and dies.

A mod is different. It loads once, stays in the session, keeps state, and its hooks are middleware: observe, rewrite, or answer each event in-process. It can also draw UI and call back into Claude Code.

The docs put it well: if a settings hook, a skill, or an MCP server already does what you need, compare before you write a mod. I did the comparing. Here is what I found.

Migration 1: the guard (shorter)

Before. A PreToolUse hook in settings.json pointing at a bash script:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "~/.claude/hooks/guard.sh" }]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode
#!/bin/bash
# guard.sh: read the event JSON from stdin, block risky commands
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
case "$cmd" in
  *"rm -rf"*|*"git push --force"*)
    echo "Blocked: too risky to run unattended" >&2
    exit 2 ;;
esac
exit 0
Enter fullscreen mode Exit fullscreen mode

Two files, a jq dependency, a subprocess spawned on every single Bash call, and the "UI" is a line of stderr.

After. One module:

export function register(on) {
  const RISKY = [/rm -rf/, /git push --force/];

  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
    const cmd = e.input.command ?? "";
    if (RISKY.some((re) => re.test(cmd))) {
      return { deny: "Blocked: too risky to run unattended" };
    }
    return next(e);
  });
}
Enter fullscreen mode Exit fullscreen mode

No subprocess per event. No jq. The deny path is a first-class return value, not an exit code convention. And because the mod is in-process, the same guard can later grow a UI, a panel with "what would this change" and Proceed/Cancel buttons, like Anthropic's own blast-radius sample mod. A hook can never do that part.

Verdict: shorter, and upgradeable. This is the migration that sells itself.

Migration 2: the notifier (impossible as a hook)

Before. My "tell me when a long turn finishes" hook. The problem: a settings hook can allow, block, or log an event. It cannot draw anything. The closest I got was appending to a log file and having a separate terminal tailing it. It worked, and it was embarrassing.

After. A mod hooks turn.complete and draws. The official docs use exactly this as their canonical example: count tool calls and show the count beside the spinner while Claude works, Thinking · tool calls: 3…, updating live. My notifier became a small band above the prompt showing turn duration and token delta, updating after every turn.

This one was not a migration. It was a capability that did not exist in my setup before. Hooks observe the world through a keyhole; mods live in the room.

Verdict: impossible before, trivial now. If your hook wishlist ever included the words "show me", this is your reason to migrate.

Migration 3: the auto-approver (the hole)

Before. A hook that approved safe reads. It exited 0 for cat, ls, git status, and let the permission system handle the rest.

After. A mod that answers tool.call events for the same safe list without calling next.

And here is the part I did not expect. While testing, I learned something from the docs that stopped me cold:

A mod that approves tool calls can approve one that an ask rule would prompt for, or that one of your own PreToolUse hooks blocked.

My old hook setup had a clear hierarchy: hooks were the bouncer, the permission rules were the policy. A mod does not sit inside that hierarchy. It sits above it. A mod can approve the exact call my Migration 1 guard denies. The guard and the approver are now peers in a middleware chain, and chain order decides who wins.

I am not saying "do not migrate your approver". I am saying: when you do, you are moving a security decision from a subprocess the engine invokes into code that runs with your full permissions and can overrule the other guards. Audit the chain order. Write a test that asserts your guard still denies when the approver is loaded. claude plugin test exists for exactly this.

Verdict: migrated, but it changed the trust model. This is the one migration I would do differently: I would have read the permissions docs first.

Migrations 4 and 5: the boring ones (kept as hooks, then moved anyway)

The file-edit logger and the context counter. Both were pure "observe and record" hooks with no UI and no decisions. The docs are honest about this case: if a hook already does what you need, a mod is not automatically better.

I migrated them anyway, for one reason: state. A settings hook is stateless across events; every invocation starts cold. My logger shelled out to append to a JSONL file, and the counter re-read it. The mod versions keep counters in memory (and in $.state, which survives hot reloads) and share them between hooks, one hook counts, another draws. Less I/O, no temp files, no parsing.

Were they shorter? Marginally. Was it worth it? For the shared state alone, yes.

What I would tell you before you start

  1. Migrate the UI-shaped ones first. Anything your hooks do that wishes it could draw, notifiers, dashboards, review views, is where mods win by a mile.
  2. Keep the pure block/allow/log hooks if they work. A mod is not a moral upgrade. It is a different tool.
  3. Test the chain, not just the mod. claude plugin validate checks one plugin. claude plugin test exercises hooks against the real engine. Neither tells you what happens when your guard and your approver load together. Write that test yourself.
  4. Re-read the permissions docs after migrating. Your mental model of "hooks enforce policy" does not survive contact with mods. The mod is code with your permissions, and it can approve what your hooks blocked.

Three shorter, one newly possible, one that taught me something uncomfortable. Not a bad weekend.

Which of your hooks is secretly wishing it could draw UI? That is the one to migrate first.

Top comments (0)