FACT — the file shape. A minimal mod is three files:
first-mod/
├── .claude-plugin/
│ └── plugin.json
└── hooks/
├── hooks.json # { "modules": ["./register.js"] }
└── register.js # export function register(on) { ... }
The modules key in hooks/hooks.json is precisely "what makes the plugin a mod".
FACT — the three arguments. $ — the mods API, namespaced ($.ui, $.fs, $.process, $.http, $.env, $.settings, $.store, $.state, $.clock, $.command, $.tool, $.mcp, $.model, $.session, $.agent, $.prompt, $.telemetry, $.audio, $.config). e — the event input as plain, deeply frozen data. next — the continuation.
FACT — the runtime environment. A hooks module runs in an environment of its own: no DOM, no Node, no require, no dynamic import(). Modules and their imports must be named .ts .tsx .jsx .js .mjs .cjs .mts .cts and be ES modules. Only relative imports inside the plugin directory are allowed, with one bare import permitted: claude-code. Because ambient access is removed, a static scanner can enumerate the $ calls — which is exactly what claude plugin validate prints and what makes a policy mod's plugin.register check possible.
FACT — matcher filters. A matcher is an object compared against the event: a value, an array of allowed values, or a regular expression (on('tool.call', { tool: /^mcp__github__/ }, hook)). Wildcards work: 'classic.*' matches every settings-hook event, '*' matches every event except telemetry events.
FACT — the event surface, read out of Anthropic's own generated declarations. The engine event types declared in claude-code.d.ts (retrieved from the shipped repository; see §19) are:
- Tools:
tool.call, tool.check, tool.register, tool.list, tool.describe
- Prompts / context:
prompt.submit, prompt.compose, prompt.edit, prompt.fill, prompt.read, prompt.section, prompt.context, prompt.suggest, prompt.attachment, skill.prompt
- Turns:
turn.start, turn.step (streaming, written as an async generator), turn.complete, turn.abort
- Session:
session.start, session.end, session.id, session.cwd, session.root, session.messages, session.usage, session.measure, session.compact, session.attach, session.detach, session.receive, session.send, session.model, session.repo, session.surface, session.surfaces, session.turns, session.version, session.authorize
- Interface:
ui.render, ui.resolve, ui.press, ui.input, ui.select, ui.focus, ui.open, ui.close, ui.panes, ui.scroll, ui.log, ui.message, ui.notice, ui.toast, ui.status, ui.invalidate, ui.blit
- Commands / config / plugins:
command.register, command.run, command.list, command.describe, config.list, config.describe, config.set, plugin.register, engine.create
- Agents:
agent.spawn, agent.offer, agent.register, agent.list
- Attribution:
attribution.text
- Classic mirror:
classic.<SettingsHookEvent> — every settings-hook event is reachable from inside a mod
- Telemetry: separate, taking
{ to: 'collector' }
FACT — the ordering rule. Hooks on the same event form one middleware chain. "The first mod is outermost: it sees the event before the others and the result after them, and it decides whether the others run at all." The order is: (1) sec-default@builtin, then org prependPlugins, then any other org mod; (2) user-installed mods; (3) org appendPlugins; (4) other built-in mods. Within a tier, load order; a mod runs before its manifest dependencies.
FACT — where settings hooks sit in that chain, precisely. Managed-settings PreToolUse hooks run before the first mod's tool.call, and their block is final — no mod sees the call. PreToolUse hooks from every other settings file and from plugins run after the last mod calls next. tool.check fires after those hooks and the permission rules have decided, so a tool.check hook can approve a call that a non-managed PreToolUse hook blocked.
FACT — failure semantics. A hook that throws, times out, or returns a wrong shape does not break the session: if it had not called next, Claude Code skips it and the next handler runs; if next had already resolved, that result stands. on(...).catch(handler) attaches a fail-closed handler — the documented use case is making a blocking hook fail closed instead of open. Time spent awaiting inside a $ call (e.g. $.ui.ask) does not count against the hook's time limit; time spent on the mod's own promise does.
FACT — the one hard boundary. "A mod can restyle much of Claude Code's interface, but not the permission prompt. It can't change what a prompt shows you." This is stated in the blog post, the overview page and the admin page.