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 (4)

Collapse
 
sinarezaei profile image
Sina Rezaei •

The interesting part for me isn't really that the mods version has less code. It's the change in where the middleware actually lives.

The next(e) model makes this feel much closer to Express/Koa middleware than the old shell-based hook model. You can inspect a tool call, rewrite it, stop it, or pass it further down the chain without spawning another process for every event.

A practical example would be a Bash guard that blocks destructive commands. With a classic hook, you might parse the command and return an exit code:

if [[ "$cmd" == *"rm -rf"* ]]; then
  echo "blocked"
  exit 2
fi
Enter fullscreen mode Exit fullscreen mode

With a mod, that policy can sit directly in the tool.call chain:

on("tool.call", { tool: "Bash" }, async ($, e, next) => {
  const cmd = e.input.command ?? "";

  if (/rm\s+-rf|git\s+push\s+--force/.test(cmd)) {
    return { deny: "Blocked by command policy" };
  }

  return next(e);
});
Enter fullscreen mode Exit fullscreen mode

But the more interesting architectural consequence is that another mod can also intercept the same call. Once you have multiple handlers deciding whether a tool call continues, ordering becomes part of the security model.

That's a much bigger change than simply replacing a shell script with TypeScript.

I'd probably migrate UI/state-heavy hooks first, but I'd be much more conservative with anything responsible for authorization or destructive-command protection. Moving that logic in-process gives you more power, but it also means you need to reason about the whole middleware chain, not just one hook in isolation.

Collapse
 
pawel_nowak profile image
Pawel •

You're right that the pipeline placement is the real story, not the line count. The Express/Koa comparison is spot on, and I like your framing of ordering as part of the security model. My addition would be that ordering only matters if you trust the whole mod list: a guard's position in the chain means nothing if another mod can approve what it denies. Your migration order matches mine exactly: UI-heavy hooks first, restrictive ones only after re-reading the permissions docs.

Collapse
 
sinarezaei profile image
Sina Rezaei •

Exactly. That’s the part I’d be most careful about too: a deny rule is only as strong as the trust boundary around the middleware chain.

It also makes me think the mod system eventually needs something closer to policy composition rather than just execution order. For example, if one mod can deny a destructive operation, another shouldn’t be able to silently turn that decision back into an allow just because it runs later.

At that point I’d want explicit semantics like deny-wins, scoped permissions, or even a final authorization layer after all mods have run.

Otherwise you’ve basically moved the security problem from “which hook runs?” to “which mod gets the last word?” 🙂

Thread Thread
 
pawel_nowak profile image
Pawel •

Yes, this is exactly where my thinking landed too. Execution order as the security mechanism feels wrong, because order is an accident of install sequence, not a deliberate policy decision. A deny-wins default would be the minimal sane fix: once any mod registers a deny, the chain short-circuits and nothing later can flip it back to allow. Until the platform offers that, my working rule is keeping deny logic in one audited mod and treating every other mod as untrusted middleware. The final authorization layer you describe is the cleaner long-term answer.