← Back to Blog

Claude Code Hooks Just Moved Inside the Process

A hook is something Claude Code asks from outside when something happens. A Mod is a function inside the process, so it can wrap the engine and draw on the screen. Three pictures, one twelve-line mod, and by the end you can read what any mod can touch before you install it.

Skip to trying one →

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.

TL;DR

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

Two panels. Left, a hook: the event goes out of the Claude Code process to your hook as JSON, and its answer comes back to the engine. Right, a Mod: the event reaches your function inside the process, next(e) passes it to the engine, and the result returns through your function.
Left, the hook you have today. Right, a Mod. The difference is where your code sits.

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

Your mod's code reaches the screen, files, processes, the network and the model only through one door labelled $. A dashed attempt to go around the wall is crossed out. A side panel shows claude plugin validate printing the calls line and a reach level, above a four-level reach ladder from L0 to L3.
Every side effect is a call on $, 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

Four nested rings: an admin mod registered first on the outside, then a team mod, then your mod, then the engine in the centre. An arrow enters through every ring and the result leaves through every ring. A side note explains that removing $.process.run in the outer ring means no mod inside can ever call it.
The first mod registered wraps every mod after it. An event goes in through each ring and the result comes back out through each ring.

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

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