Every Claude Code hook you have written so far runs outside the engine. A shell command, an HTTP endpoint or an MCP tool gets a JSON payload for one event, answers, and Claude Code carries on. A PreToolUse hook can block a command or rewrite its input from out there. It cannot wrap what happens next.
This month the hook can be a TypeScript function that runs inside Claude Code itself. It sits in the path. The same function sees the event, calls the engine, and shapes the result, in one place. Anthropic calls these plugins Claude Mods. The primitive underneath is a function hook; a Mod is a plugin that uses them.
I have scanned every mod I could find on GitHub and shipped two. Everything I needed to read one, run one, or write one fits in three pictures.
A Mod is a plugin whose hooks are TypeScript functions loaded into Claude Code's own process. Every side effect goes through one object, $, so claude plugin validate prints what a mod can touch before it runs. Mods nest in registration order, so an admin's mod can remove a capability from every mod beneath it. It is early access, so set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 or nothing loads. Jump to trying one →
Picture one. The hook moved inside
On the left, the hook you have today. The event leaves the process as JSON, your hook answers, and the engine carries on. It can block, rewrite the input, and log. It cannot wrap the engine or draw anything, and the before and the after are two separate hooks.
On the right, the event reaches your function first. You call next(e) to let the engine run, along with every hook registered after you, and the result comes back through your function on the way out. Skip next(e) and whatever you return is the answer. Same user, same credentials, and no tokens spent unless you ask the model something.
Here is the whole idea as code. This is hello-mod, the smallest mod in my list. It logs one line when a session starts and refuses rm -rf / in Bash.
import type { Register } from 'claude-code'
export const register: Register = on => {
on('session.start', async ($, e, next) => {
const r = await next(e)
$.ui.log(`hello-mod loaded in ${e.cwd}`)
return r
})
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (typeof e.command === 'string' && e.command.includes('rm -rf /')) return { deny: 'hello-mod refused it' }
return next(e)
})
}
on registers a hook. e is the event. next is the engine plus the hooks below you. And $ is everything the hook can see or do, which is the second picture.
Picture two. One door, so you can read a mod before you run it
$, so the loader can list them before a line of mod code runs.
A mod runs inside Claude Code with the process's reach. Files, shell, network, the model. That should worry you. It worried me enough to scan every mod I could find on GitHub. What makes it readable is one rule from Anthropic's architecture paper. Every side effect is a call spelled $.noun.verb(...), and a module that reaches $ any other way fails validation.
So the loader can inventory the calls before any mod code executes, and claude plugin validate prints that inventory. For hello-mod it prints this.
❯ ./register.ts hooks: session.start, tool.call{tool=Bash}
❯ ./register.ts calls: $.ui.log
Two hooks, one call. It can draw a line and nothing else. I grade that L0. A mod that calls $.process.run is L2 and can run commands on your machine; $.http.fetch is L3. The nightly scan behind awesome-claude-code-mods does this for every mod it can find on GitHub. As of September 17 it had 72 mods across 142 candidate repos. 30 run host processes and 14 reach the network.
The footprint tells you what a mod can touch, and that is all it tells you. A PR tracker built on gh has to run processes. The point is that you can read it before you install, and the scanner reads it for you.
Picture three. Who wins when mods disagree
Mods nest like middleware. Koa, if you have used it. Express, roughly. The first one registered is the outermost ring, so it sees the event first and the result last.
The consequence is the one to notice if you run Claude Code for a team. An admin's mod sits outermost. If it removes $.process.run from $, no mod inside can ever call it. Not yours, not the team's, not one installed next month. In Anthropic's own words from the design thread, "admins prepend for control and append for defaults."
Try one in two minutes
git clone https://github.com/karanb192/awesome-claude-code-mods
cd awesome-claude-code-mods
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir examples/hello-mod
Two things happen. The session opens with a line from the mod, hello-mod loaded in followed by the folder you started in. Then ask Claude to run rm -rf /. The Bash call is refused before it runs, and the transcript shows hello-mod refused it. That is the whole mod, three files, loaded for this session only because the flag is set.
Find one, or build one
To find one, every row in awesome-claude-code-mods carries the footprint the validator printed and a reach badge, rescanned nightly. The scoreboard page is the same data sorted by reach.
To build one, mod-builder is a skill that makes you write the plan before the code. It asks which events the mod observes and which $ verbs it needs, states the reach level, waits for a yes, writes the three files, runs the validator, and diffs the printed calls against the plan. Anything not in the plan gets removed or gets a written reason. It ends with a five-line threat model.
Both of my mods came out of it. fable-pin rewrites agent.spawn so every subagent runs on one model. cache-tax keeps the prompt cache warm from inside the process with a tool-less fork, which a shell hook cannot do. It also cannot write into the shell status line you configured, so the countdown row still comes from the hook form. A Mod is not all-powerful, and that is by design.
The gotchas
- Early access. Nothing loads without the flag, and the API can change between releases. On Claude Code 2.1.274, 3 of the 72 scanned mods fail to validate.
- Run
/plugin-typesinside a session beforetsc. Several mod READMEs report the build failing until the types are generated. Do not commit the.claude/types/it writes. - A hook on
tool.callwith no matcher sees every tool call, andprompt.submitsees every prompt you type. Read thehooks:line as carefully as thecalls:line. - Two names, one thing. Anthropic calls the primitive function hooks and the product Claude Mods.
Set the flag once, load hello-mod, and read its calls: line. After that, every mod you meet is the same three pictures.
Sources and code
- My hooks post, for the shell-hook side of picture one
- Anthropic's hooks reference, including PreToolUse
- The design thread that named Claude Mods, with Anthropic's videos, cheat sheet and updates
- Function Hooks: Core Architecture (PDF)
- Anthropic's built-in mods and type declarations
- awesome-claude-code-mods, the nightly scan and hello-mod
- mod-builder and fable-pin
- cache-tax, the Mod
- The scoreboard post, with the reach rules