Hook Chain

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

NameWhat 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 nexte 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 Rewrite Answer e: rm -rf build e: ls -la e: git push -f your hook: count it your hook: trim it your hook: { deny } engine runs it engine runs "ls -la" engine never sees it next(e)next({ ...e, command })no next
// 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:

FamilyExamplesTypical use
Toolstool.call, tool.check, tool.describeGuard, rewrite, answer or post-process a tool call. Approve or deny at the permission check. Rewrite a tool's description.
Promptprompt.submit, prompt.compose, prompt.mention, prompt.autocompleteRewrite or drop a prompt, add hidden context, add a system-prompt section, guard an @-mentioned file.
Turnturn.start, turn.step, turn.completeFollow a turn. Stream or reroute a model request. Print a receipt under the answer.
Sessionsession.start, session.end, session.append, session.compactRegister commands at start, redact rows as they are stored, shape compaction, save on exit.
Interfaceui.render, ui.press, ui.inputDraw a pane or a band, restyle the spinner or a tool row, handle buttons and fields.
Agents and commandsagent.spawn, agent.offer, command.runPin a subagent's model, hide an agent type, answer your own slash command with no Claude turn.
Pluginsplugin.register, engine.createAdmit 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 CodeHooks runDrawings appear
claude in a terminal, including IDE terminals and JetBrainsYesYes
The Code tab of the Desktop app (not WSL sessions)YesYes, except terminal-only elements
VS Code extension chat panelYesNo
claude -p and the Agent SDKYesNo
Remote Control from the web or phoneYes, on your machineOnly in your local terminal
Cloud sessionsIf the plugin reaches themNo

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

Built-in mods such as /diff's pane keep running under all of these. They are listed separately in /plugin.

What to remember

  1. Three files: plugin.json, hooks.json with modules, and one hooks module.
  2. Every hook is ($, e, next). It can observe, rewrite or answer.
  3. Everything outside the module goes through $, so claude plugin validate can list it.
  4. Hooks run everywhere the plugin loads. Drawings show only in the terminal and the Desktop app.
  5. 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.