Skip to content
Skip to main content
Five boutique guitar effects pedals chained with patch cables on a wooden floor, one glowing red as if blocking the signal, a metaphor for Claude Code mods running as a middleware chain that can stop a tool call
8 min readBy Carlos Aragon

Claude Code Mods vs Hooks: Which One Should You Use?

Use a settings hook when you want to block, allow or log an event with a script you already have. Use a modwhen you need something a script can't do from outside: draw a pane, rewrite a prompt or tool call, add an instant slash command, or keep state between events. Mods landed in Claude Code 2.1.287 on 1 October. I built the same secrets guard both ways this morning, ran them headless, ran them together, and timed the hook. The results changed which one I'd reach for, mostly because of who runs first.

What is a Claude Code mod?

A mod is a plugin with a JavaScript or TypeScript module that registers event handlers. Claude Code calls them in its own process when something happens: a tool call, a submitted prompt, a request to the model, or a piece of the interface being drawn. Each handler gets ($, e, next). Call next(e) to let things continue, pass a modified copy to rewrite the event, or return without calling it to answer the event yourself.

If you've written Express or Koa middleware, you already know the model. Handlers for one event form a chain, the first mod is the outermost, and the end of the chain is Claude Code's own behavior. Anthropic's mods overview even renames things to keep them apart: inside the mods docs, "hook" means a mod's handler, and the old kind is a "settings hook". I'll use the same words here.

The short version: settings hooks talk to Claude Code through stdin, stdout and exit codes; mods are code Claude Code calls directly. Everything else in this post falls out of that.

Mods vs settings hooks: what can each one do?

 Settings hookMod
What you writeAny script + a settings.json entryJS/TS module inside a plugin
Where it runsNew process (or HTTP call) per eventInside Claude Code
Block / allow a tool callYesYes
Rewrite a prompt or tool argsTool args and added contextPrompts, tool calls, model requests, system prompt sections
Draw UI (pane, band, spinner)NoYes, terminal and Desktop
Slash command with no Claude turnNoYes, even while Claude works
State between eventsOnly via files you manageModule variables, $.state, $.store
LanguageAnythingJavaScript / TypeScript only

The UI column is the headline feature, but for most of my setups it's the least important row. The ones that matter are rewriting and ordering, because that's where a guard either holds or doesn't.

I built the same .env guard both ways

The test: stop Claude from reading or cat-ing .env files, a rule I already run as a PreToolUsehook on the Mac mini that hosts my agents. Here's the whole mod, in hooks/register.ts:

import type { Register } from 'claude-code'

const ENV = /(^|[\s\/'"])\.env(\.[a-z]+)?\b/
let blocked = 0

export const register: Register = on => {
  on('tool.call', { tool: ['Read', 'Edit', 'Write'] }, ($, e, next) => {
    if (!ENV.test(e.file_path)) return next(e)
    blocked += 1
    $.ui.status(`env-guard: ${blocked} blocked`)
    return { deny: `env-guard: ${e.file_path} holds secrets. Ask the user for the specific value instead.` }
  })
  on('tool.call', { tool: 'Bash' }, ($, e, next) => {
    if (!ENV.test(e.command)) return next(e)
    blocked += 1
    $.ui.status(`env-guard: ${blocked} blocked`)
    return { deny: 'env-guard: shell access to .env files is blocked. Ask the user for the specific value instead.' }
  })
}

Plus two tiny JSON files: .claude-plugin/plugin.json with a name and version, and hooks/hooks.json containing { "modules": ["./register.ts"] }. The settings-hook version is a 9-line Python script that reads the event JSON from stdin, checks tool_input.file_path or tool_input.command, and exits 2 with the reason on stderr.

Before running anything, I validated the mod:

$ claude plugin validate ./env-guard
  ❯ ./register.ts hooks: tool.call{tool=Read|Edit|Write}, tool.call{tool=Bash}
  ❯ ./register.ts calls: $.ui.status
✔ Validation passed with warnings

That hooks: / calls: output is the best thing about the design. A mod can only touch the outside world through $, so the validator can list every file read, process and network call a mod makes without running it. You can't get that from a shell script.

What happened when I ran them?

All runs were claude -p --model haiku --permission-mode bypassPermissions in a scratch project with a fake .env.

  • Mod alone, "read .env and tell me SECRET_TOKEN":denied. Claude quoted the deny text word for word and didn't print the value. The whole run took 8.1 seconds.
  • Settings hook alone, "run cat .env":denied with the hook's stderr message.
  • Both installed, "run cat .env": Claude quoted the mod'smessage, not the hook's.

That last result is the one to remember. A mod's tool.call hook runs before the PreToolUse hooks in your user, project and plugin settings. When the mod denies without calling next, those hooks never run at all. The events docs confirm it: only PreToolUse hooks from managed settings run before mods, and their block is final.

Also note bypass mode didn't matter. Both guards held under bypassPermissions, which is how most of my unattended runs are configured. If you run agents headless the way I described in my sandbox and permissions setup, either one works as a backstop.

One honest footnote: my global CLI was 2.1.286, one release before the documented minimum, and the mod still loaded through --plugin-dir. That's the early-access build. Don't depend on it; update to 2.1.287 or later.

Is a mod faster than a settings hook?

Yes, but it rarely matters. I timed each hook script 50 times with a sample event on stdin, which is roughly what Claude Code pays to spawn it on every matching tool call:

  • Python script: median 18.4 ms, p95 25.5 ms
  • Bash script: median 4.9 ms, p95 5.5 ms
  • Mod:no process spawn; it's a function call

Against a model turn measured in seconds, 18 ms per tool call is noise. Where it adds up is a hook with a broad matcher (every tool, every call) inside a long agent session with hundreds of calls, or a hook that does real work like a network check. Speed is a tiebreaker, not a reason to rewrite working hooks.

When should you pick a mod?

Pick a mod when the job needs one of these, because a settings hook can't do them:

  • Live UI. A pane charting context use, a band above the prompt showing CI status, a count beside the spinner.
  • Hold a call and ask. $.ui.askpauses a risky command and puts buttons in front of you. Anthropic's blast-radius sample does this for rm -rf and force pushes.
  • Route a request. A turn.step hook can send one request to a different model, or log cache reads per request. That pairs well with the cost work in subagent token costs.
  • Instant commands. A /deploy-status that runs your function immediately, no turn, no tokens.
  • Decisions based on live state. tool.check can deny git push only while you're on main.

When is a settings hook still the better choice?

Most of the time, honestly. I'm keeping my existing hooks from the production hooks setup as they are. A settings hook wins when:

  • You already have the script. A linter, a formatter, a Slack webhook. Wrapping it in TypeScript buys nothing.
  • It isn't JavaScript. Python or Go tooling stays a hook.
  • It must reach the VS Code panel or a cloud session without a plugin install. Mods ship as plugins; a hook is one line in a settings file you probably already sync.
  • You want the smallest trust surface.A hook sees one event's JSON. A mod sees the whole session.

What are the security risks of mods?

Mods are not sandboxed.They run as you, can read your env vars and settings, see every prompt, rewrite tool calls, and approve a call before you're asked. A tool.check hook can even approve something one of your own non-managed PreToolUsehooks blocked. Turning on Claude Code's sandbox only isolates Claude's Bash commands, not processes a mod starts.

Three rules I'm applying:

  1. Run claude plugin validate on every third-party mod and read the calls: line. A "spinner theme" that calls $.fetch gets deleted.
  2. Make guards fail closed. A hook that throws or times out is skipped and the chain continues, so the command runs. Attach a .catch that returns a deny.
  3. Put real policy in managed settings. On Team and Enterprise, the built-in sec-default mod loads first and stops user mods from overriding deny rules. For a client team, ship guards through a private plugin marketplace and pin them with prependPlugins.

So which one should you use?

Default to a settings hook. Graduate to a mod when you want to see something, rewrite something, or hold state. And if you care about guards, remember the order: a mod you install runs ahead of your project hooks, so one bad mod can make those hooks irrelevant. The same logic I used in skills vs MCP applies here: pick the smallest thing that does the job.

If you're rolling Claude Code out to a team and want guards, mods and plugin distribution set up without the trial and error, tell me what you're running and I'll tell you what I'd lock down first.

Tested 3 October 2026 on macOS with Claude Code 2.1.286 (CLI) and claude plugin validate, headless runs on Claude Haiku. Hook timings are 50 runs per script with a sample PreToolUse payload. Ordering and security behavior also checked against Anthropic's mods documentation.

Related Posts

AI Agents

How to Build a Private Claude Code Plugin Marketplace for Your Team

A private Claude Code plugin marketplace is a git repo with one file in it: .claude-plugin/marketplace.json. Your team runs /plugin marketplace add your-org/claude-plugins and installs what they need, and the git host handles who is allowed to see it — there is no server to run and no permissions layer to build. The setup is a manifest and a catalog. What actually costs you an afternoon is two things nobody warns you about: the background refresh disables git credential helpers, so private HTTPS auto-updates fail while manual ones work, and omitting the optional version field means every commit ships as a new release to everyone who installed the plugin.

AI Agents

Claude Code Hooks: The 6 I Actually Run in Production (2026)

Hooks are shell commands that fire automatically at lifecycle events — so you enforce a rule instead of hoping the model remembers it. A prompt is a suggestion; a PreToolUse hook is a wall the agent can't walk through. The six I put on every autonomous Claude Code agent: block secret writes, protect .env and migrations, auto-format edited files, run fast tests, inject git context on session start, and ping Telegram when the run finishes.

AI Agents

Claude Code GitHub Actions Cost: The Real Math

Claude Code in GitHub Actions bills two lines: runner minutes at $0.006/min and Claude tokens. The minutes are noise. The tokens cost more than on your laptop because a fresh runner never hits the prompt cache — so every run re-reads your system prompt, tools and CLAUDE.md at full price, whether the PR changed 400 lines or one. The unit of cost is the run, not the diff.