A mod is a plugin that carries a hooks module. That is the whole definition, and Anthropic uses the word themselves: the directory in Claude Code's own repository is called mods/, and the cheat sheet they published on 2026-09-09 is titled Claude Mods, the $ cheat sheet. The primitive underneath it is the function hook, which is one async function registered against one event, and the runtime composes those functions as middleware.
If you have written Express or Koa middleware you already have most of it. A hook receives three things, does whatever it wants with the first two, and decides whether to call the third:
export function register(on) {
on("tool.call", { tool: "Bash" }, async ($, e, next) => {
if (e.command.includes("rm -rf /")) return { deny: "no" }
const r = await next(e) // every hook beneath, then core
return { ...r, text: redact(r.text) } // refine on the way up
})
}
$ is the engine interface and it is the only door to a side effect. e is the event, a flat value whose identifiers are pinned and whose payload you may rewrite. next is the rest of the chain beneath you, reified as a function you can call zero times, once, or several.
It is off unless you turn it on
Nothing here runs on a stock install. The runtime is gated, and a mod that loads with the gate shut fails quietly in a way nobody notices:
hooks modules not loaded: rollout flag (tengu_plugin_hooks_modules) is off
The plugin itself loads. Its commands and skills work. Only the hooks module is dropped, and the line above goes to the debug log rather than anywhere a person is looking. Set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the environment, or in settings.json under env, and it loads. The env route works and was confirmed by the community at 2.1.261.
Everything on this site was read against build 2.1.272 on Linux. The declarations Claude Code generates about itself open with a line worth taking at face value:
EARLY ACCESS: this surface may change between releases without notice.
That is not boilerplate. Between 2.1.263 and 2.1.272 the filesystem ops were renamed from fs.readFile, fs.writeFile and fs.listDir to fs.read, fs.write and fs.list, with no migration path and no deprecation, so every mod written against the older names broke. Pin the build you tested on and re-read the declarations when you upgrade.
Anthropic ships three mods, and one of them is the diff pane
The best argument that this runtime is real is that Claude Code's own features are built on it. Three mods ship inside the binary:
| Mod | Files | What it is |
|---|---|---|
diff |
775 | The /diff pane, drawn beside the transcript, refreshed as Claude edits |
sec-default |
60 | The org security baseline, seated outermost on managed machines |
telemetry |
81 | Adds $.telemetry to the engine interface on internal builds |
All three declare {"modules": ["./register.ts"]} and nothing else. The diff pane is 900-odd lines in its register.ts alone and uses eleven distinct events, which means every capability that pane uses is a capability your mod has too. There is no private API holding it up.
The fold
Hooks nest. The engine composes them into a single function and the composition is an onion, with core innermost:
X = A ∘ B ∘ C ∘ core = A(B(C(core(⊥))))
Earlier registration wraps more, so position is authority. On the way down each link may refine the question, core answers it by default, and on the way up each link may refine the answer. There are five tiers and they are covered in full on the tiers page, but the shape to hold in your head now is that your hook sits somewhere in a stack and the thing beneath you is a function call.
The cheat sheet states two axioms as a pair, and they are the entire contract for what next means:
core has a side effect: no
next= it did not happen,nexttwice = it happened twicecore has no side effect
On tool.call the first one applies. Calling next is what runs the tool. Returning without calling it is what stops the tool, and returning { deny: "..." } after you already called it tells the model the call failed while the file is already on disk. Anthropic's own words on this, from the issue thread:
The return of 'deny' here is not somehow making the tool not get called. To make the tool not get called, don't call
next(e). When you hook ontotool.call, you are responsible for calling the tool.
Where the code actually runs
Each plugin gets its own worker. The debug log says so outright:
hooks worker spawned (one for every plugin)
hooks module armD loaded (worker, environment 1, tier user); events: tool.call
engine.create: no plugin-provided interfaces; $ built for armD
The implementation is a Bun worker around the whole plugin realm with a node:vm wrapper per plugin, though the implementation is explicitly not the contract. The contract is that you get no ambients. There is no process, no require, no Bun, no global fetch, no Worker, no navigator. Importing node:fs or node:child_process is refused by the loader with "a hooks module imports its own files by relative path and claude-code, nothing else". There is no WebAssembly either, which is the thing that has actually bitten people building games above the prompt.
What you do get is 87 globals of plain ES2023 on web APIs: timers, crypto, structuredClone, typed arrays, and the standard library. Everything that reaches outside the process goes through $, which is the point. $ is the plugin's capability table, and an org can withhold nouns from it.
These are not the settings hooks
Claude Code already had hooks, the ones you configure in settings.json that shell out to a command and speak JSON over stdin and stdout. Those still exist and they still work. Function hooks are a different mechanism and they sit above the old ones: every settings hook is wrapped one to one as classic.<Event> with its exact JSON going in and out, and on a Bash call the tool.call chain logs first and settles last, so it wraps PreToolUse entirely.
The practical difference is speed and expressiveness. A settings hook is a process launch. Twenty-five pass-through function hooks add 5 ms over having none at all, measured at 2.1.260, while a single command hook sleeping 50 ms turns a 15 ms dispatch into 166 ms. Function hooks are also strictly serial, which is the tradeoff: eight function hooks each sleeping 300 ms settle in 2,427 ms, where eight command hooks doing the same settle in 640 ms because they run in parallel. Serial is what makes the onion an onion.
There is one more thing the old hooks cannot do, and it is most of why this runtime exists. A function hook can read and rewrite the value flowing through it, in both directions, with types.
The smallest mod that does something
Four files, and one of them is optional:
my-mod/
.claude-plugin/plugin.json { "name": "my-mod", "version": "0.0.1", "description": "..." }
hooks/hooks.json { "modules": ["./hooks.ts"] }
hooks/hooks.ts export function register(on) { ... }
README.md
import type { On } from "claude-code"
export function register(on: On) {
on("session.start", async ($, e, next) => {
await $.ui.log(`mod up in ${e.cwd}`)
return next(e)
})
}
Run it with claude --plugin-dir ./my-mod. That path hot-reloads on save, which is the only load path that does: a mod installed under ~/.claude/skills/ served stale code through three runs at 2.1.270 and only picked up an edit after a restart. Worth knowing before you spend an hour debugging a change that did apply.
What this guide covers, and what it does not
The next two pages are the model: how a mod is put together, and how the chain composes. After that the pages are reference, and you can read them in any order. The gotchas page is the one to read before you ship anything, because the failure semantics of this runtime are not what the shape suggests and one of them turns a security guard into a no-op.
What is not here is every field of every event. The declarations run to 10,843 lines and a good deal of that is the shape of tool inputs you already know from using Claude Code. Where a page states a behaviour it either quotes the declarations at 2.1.272 or names the run that produced it, and where I could not measure something I say so rather than repeating what somebody else measured on an older build.