Level 1 What a mod is
Mods 101
A mod is a plugin whose code Claude Code runs inside its own process. The code registers functions called hooks on named events: a tool call, a submitted prompt, a part of the screen being drawn. Each hook joins a chain in front of Claude Code's own behaviour and decides what happens next.
Three files
A small mod is a folder with three files. Nothing needs installing or building, because Claude Code loads TypeScript directly.
tool-counter/
├── .claude-plugin/
│ └── plugin.json the plugin's manifest: name, version, description
└── hooks/
├── hooks.json { "modules": ["./register.ts"] }
└── register.ts your code: the hooks module
The modules key in hooks.json is what makes a plugin a mod. It names exactly one file. Without it the plugin still validates, but it has no mod code. The same plugin can also carry skills, agents, MCP servers and settings hooks.
A complete mod
This is the example from the official overview. It counts tool calls and adds the count to the spinner, so while Claude works you see Thinking · tool calls: 3….
// The count, shared by the two hooks below
let calls = 0
// Claude Code calls this once when the mod loads
export function register(on) {
// Runs each time Claude is about to use a tool
on('tool.call', async ($, e, next) => {
calls += 1
$.ui.invalidate('ui.render') // ask for a redraw so the count shows
return next(e) // let the tool run as usual
})
// Runs each time Claude Code draws the spinner
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// Keep Claude Code's spinner, with the count added after its word
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
}
Load it for one session without installing anything:
claude --plugin-dir ./tool-counter
Run /plugin and a dim line under the tabs says 1 mod active · tool-counter. The folder is watched, so saving register.ts reloads the mod in place.
The four names
| Name | What it is |
|---|---|
register(on, options) | The function your module exports. Claude Code calls it once per load. options holds the values of the fields your manifest's userConfig declares. |
on(event, matcher?, hook) | Adds a hook to an event's chain. The optional matcher narrows it, for example { tool: 'Bash' } or { component: 'Spinner' }. |
$ | The mods API: drawing, commands, tools, model calls, files, processes, network, store, clock, settings and more. It is the module's only way to reach anything outside itself. |
e and next | e is the event as a plain value. For a Bash tool call, e.command is the command. next(e) passes the event on to the rest of the chain and resolves to its result. |
The three moves
Claude Code runs your hook before it acts on the event, so the hook decides what happens. It has three choices:
// Observe: note it, change nothing
on('tool.call', ($, e, next) => { log(e); return next(e) })
// Rewrite: the rest of the chain sees the new event
on('tool.call', { tool: 'Bash' }, ($, e, next) => next({ ...e, command: e.command.trim() }))
// Answer: return a result without calling next, so nothing beneath runs
on('tool.call', { tool: 'Bash' }, ($, e, next) =>
/\bgit\s+push\b.*--force/.test(e.command) ? { deny: 'Ask the person to force-push themselves.' } : next(e))
A hook can also await next(e) and then change the result on its way back up, or call next twice to retry. Because each hook wraps the ones after it, the chain works like middleware in a web server.
What a mod can hook
The 2.1.296 declarations list 46 engine events, plus every settings-hook event as classic.<Event>. They fall into a few families:
| Family | Examples | Typical use |
|---|---|---|
| Tools | tool.call, tool.check, tool.describe | Guard, rewrite, answer or post-process a tool call. Approve or deny at the permission check. Rewrite a tool's description. |
| Prompt | prompt.submit, prompt.compose, prompt.mention, prompt.autocomplete | Rewrite or drop a prompt, add hidden context, add a system-prompt section, guard an @-mentioned file. |
| Turn | turn.start, turn.step, turn.complete | Follow a turn. Stream or reroute a model request. Print a receipt under the answer. |
| Session | session.start, session.end, session.append, session.compact | Register commands at start, redact rows as they are stored, shape compaction, save on exit. |
| Interface | ui.render, ui.press, ui.input | Draw a pane or a band, restyle the spinner or a tool row, handle buttons and fields. |
| Agents and commands | agent.spawn, agent.offer, command.run | Pin a subagent's model, hide an agent type, answer your own slash command with no Claude turn. |
| Plugins | plugin.register, engine.create | Admit or refuse another mod as it loads. Add a namespace to $ for other mods. |
Every $ call is an event too (fs.read, model.complete, http.fetch). A mod that runs earlier in the chain can watch, change or refuse what a later mod asks for.
Where mods run
| Where you run Claude Code | Hooks run | Drawings appear |
|---|---|---|
claude in a terminal, including IDE terminals and JetBrains | Yes | Yes |
| The Code tab of the Desktop app (not WSL sessions) | Yes | Yes, except terminal-only elements |
| VS Code extension chat panel | Yes | No |
claude -p and the Agent SDK | Yes | No |
| Remote Control from the web or phone | Yes, on your machine | Only in your local terminal |
| Cloud sessions | If the plugin reaches them | No |
A guard keeps working in headless runs. A pane doesn't appear there, so a mod that draws should check e.surface and fall back to a transcript line or a command reply.
Trust: what a mod can reach
Mods aren't sandboxed. A mod acts on your machine as you. It can read and write files, start programs, make network requests, read environment variables and settings, see every prompt and tool call, rewrite them, approve a tool call before you're asked and spend your model usage. A process a mod starts runs outside the Bash sandbox. The one thing it can't touch is the permission prompt.
The module itself has no ambient authority: no Node, no DOM, no file or network globals. (The docs also say no timers, but our field tests found setTimeout present on 2.1.296.) Everything that reaches outside goes through $, so the loader can read a mod's source and list what it does. Before you install one, run:
claude plugin validate ./some-mod
The hooks: line lists the events the mod handles. The calls: line lists what it asks Claude Code to do, such as fs.read, process.run or http.fetch. A mod that counts tool calls and also calls http.fetch deserves a closer look.
Turning mods off
- One mod: disable or uninstall its plugin in
/plugin. - Every installed mod, for one session:
claude --safe-mode. - Every mod you installed, always:
"disableAllHooks": truein~/.claude/settings.json. This also stops your settings hooks and custom status line. What your organization manages keeps running. - Not a switch:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKSwas the early-access flag. 2.1.287 and later ignore it, so setting it to0does not turn mods off.
Built-in mods such as /diff's pane keep running under all of these. They are listed separately in /plugin.
What to remember
- Three files:
plugin.json,hooks.jsonwithmodules, and one hooks module. - Every hook is
($, e, next). It can observe, rewrite or answer. - Everything outside the module goes through
$, soclaude plugin validatecan list it. - Hooks run everywhere the plugin loads. Drawings show only in the terminal and the Desktop app.
- A mod has your permissions. Read its
calls:before you install it.
Next: Mods 201 covers writing them well: chain etiquette, guards that fail closed, state, drawing, commands and tests. Or try the facilitated tutorial and build one step by step.