Перейти к основному содержимому

Mods: In-Process Extensions

Продвинутый

A mod is a plugin that ships JavaScript or TypeScript functions which Claude Code calls inside its own process. Where a hook is a shell command that gets a JSON payload and answers with an exit code, a mod's handler sits in a middleware chain in front of Claude Code's own behavior — it can watch an event, rewrite it, or answer it so the built-in behavior never runs.

That last capability is the point. Several of Claude Code's own features are now mods: /diff and AGENTS.md loading are shipped as cc-plugin-diff and cc-plugin-agents-md, with source in the mods/ directory of the Claude Code repo. If the vendor can replace a built-in command with a mod, so can you.

What you'll learn
  • The middleware model: observe, rewrite, or answer an event — and why next(e) reaches the engine itself
  • The three files a mod needs, and the edit-reload loop with --plugin-dir
  • The static-analysis gate: why const ui = $.ui is a load error, not a style nit
  • The event map (tools, prompts, turns, session, UI) and the $ namespaces a hook can call
  • Six gotchas that bite in production: fail-open hooks, the shared worker, the 10s budget, state resets, deny-rule asymmetry, and why you must decide before calling next
  • What a mod can reach on your machine, how to audit one before installing, and the managed settings an admin uses to fence them in

Mod, hook, skill, or MCP server?​

All four extend Claude Code and they overlap. Pick by what you need to change:

ModSettings hookSkillMCP server
What it isFunctions in a plugin, run in Claude Code's processA shell command / HTTP request / prompt fired on a lifecycle eventA SKILL.md of instructionsAn external process that exposes tools
What it can changeTool calls, prompts, turns, commands, and what the interface drawsWhether an action proceeds; a tool's args and result; context for ClaudeWhat Claude knows and doesWhich tools Claude has
Can it draw UI?YesNoNoNo
You writeJS / TSA script + a settings.json entryMarkdownA server, any language
Reach for it whenYou want a pane, a band above the prompt, an instant /command, or to rewrite an eventYou already have a script that should block, allow, or logYou keep pasting the same instructionsClaude needs an external system

Nothing is deprecated: settings hooks still run, alongside mods. One plugin can carry a mod, a skill, commands and an MCP server together — see Plugins & Marketplaces.

The middleware model​

Every hook gets the same three arguments — the mods API as $, the frozen event as e, and next. The handlers for one event form a chain; the last next is Claude Code's own behavior. What you do with next decides which of three things you're doing.

ModeHowEffect
Observereturn next(e)Event proceeds unchanged. await next(e) first to act after it.
Rewritereturn next({ ...e, text })Later handlers and the engine see your copy and never the original.
Answerreturn a result, never call nextShort-circuits the chain: later mods and the built-in behavior don't run.
// Observe: log the tool, then let it run
on('tool.call', async ($, e, next) => {
$.ui.log('Claude is about to use ' + e.tool)
return next(e)
})

// Rewrite: trim every prompt before the turn starts
on('prompt.submit', async ($, e, next) => next({ ...e, text: e.text.trim() }))

// Answer: refuse force-pushes, and tell Claude why
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (/git push .*--force/.test(e.command)) {
return { deny: 'Force pushes are not allowed here. Push to a new branch instead.' }
}
return next(e)
})

Two things to internalise. First, e is deeply frozen — assigning to a field throws; you pass a copy to next. Second, when you answer a tool.call without calling next, no permission prompt appears and the tool never runs, so whatever you return is all Claude learns about what happened.

The second argument to on is a matcher: an object compared field-by-field against the event. A field can be a value, an array, or a regex.

on('tool.call', { tool: 'Bash' }, hook) // one tool
on('tool.call', { tool: ['Edit', 'Write'] }, hook) // any of these
on('tool.call', { tool: /^mcp__github__/ }, hook) // one MCP server's tools

'*' matches every event except the telemetry ones. Register each event once per matcher — two on('session.start', …) calls with no matcher fail the module to load with on("session.start") is registered twice without a matcher.

Build one in three files​

No Node.js, no bundler, no build step — Claude Code loads .js and .ts directly.

Guided walkthrough1 of 6
  1. mkdir -p first-mod/.claude-plugin first-mod/hooks — a mod IS a plugin, so it needs the normal plugin manifest at .claude-plugin/plugin.json.

The canonical starter counts tool calls and shows the count next to the spinner:

// hooks/register.js — shared by every hook in this module
let calls = 0

export function register(on) {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'tally', description: 'Show how many tool calls Claude has made' })
return next(e)
})

on('tool.call', async ($, e, next) => {
calls += 1
$.ui.invalidate('ui.render') // ask for a redraw so the new count shows
return next(e)
})

on('command.run', { command: 'tally' }, async () => {
return { text: 'Claude has made ' + calls + ' tool calls since this mod loaded' }
})

on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
}

The shared module-level calls variable is the idiomatic pattern: one hook collects, another displays. That is the thing settings hooks structurally cannot do, because each one is a separate process.

You don't have to write it by hand. Claude Code ships a built-in plugin-authoring skill (listed in /plugin as cc-plugin-plugin-authoring), so describing the mod works:

Have Claude write the mod

make a mod that shows the current git branch above the prompt

Claude writes it under ~/.claude/dev-mods/<session-id>/<mod-name>/, asks once whether to enable hot reloading for the session, and reloads it at the end of every turn that changes its files. Note the trap: that folder is per session and Claude Code deletes it once it's older than cleanupPeriodDays. Copy the directory somewhere of your own to keep the mod.

Audit a mod's capabilities before you run it

claude plugin validate ./some-mod

The static-analysis gate​

This is the part that surprises experienced JS developers, and it's the most important mechanism on the page. Claude Code refuses to load a mod whose use of $ it cannot read statically. That's what makes claude plugin validate a trustworthy capability manifest — the loader doesn't have to run your code to know what it can do.

So these all fail validation:

You wroteFailure
const ui = $.ui$.ui is used as a value
on(eventName, hook) with a variable or a loop over namesthe event name passed to on() is not a string literal
A second on declared inside register"on" is declared again (shadowed)
await import('./other.js')a dynamic import(); a hooks module imports its own files with an import declaration
require(...), or a bare import other than claude-codeModule isn't a valid hooks module
on('tool.calls', …)"tool.calls" is not an event

Write every call in full — $, namespace, method — as in $.store.get('notes'). You may pass $ to a function declared at the top level of the same file; validate then attributes the call, e.g. $.store.get (via loadNotes). Passing $ into a method, a closure defined inside the hook, or an imported function fails.

The output you're reading is this:

❯ ./register.js hooks: session.start, tool.call, command.run{command=tally}, ui.render{component=Spinner}
❯ ./register.js calls: $.command.register, $.ui.invalidate

✔ Validation passed

Mods that touch env vars or $.state also get env reads: / env writes: / state reads: / state writes: lines. --strict turns warnings into errors; --json makes it machine-readable, which is what you want in CI.

The event map​

Events fire at every point where Claude Code is about to act. The full set at v2.1.292, grouped:

GroupEventsA hook can typically return
Toolstool.call, tool.check, tool.describenext(e), { deny: reason }, { result }; tool.check returns { decision } = allow / ask / deny
Prompts & contextprompt.submit, prompt.fill, prompt.suggest, prompt.edit, prompt.compose, prompt.section, prompt.context, prompt.attachment, prompt.mention, prompt.autocomplete, skill.prompt, attribution.textChanged text, { sections }, { blocks }, { text: null } to omit, { drop: reason }; prompt.mention also takes { deny: reason }
Commands & configcommand.run, command.describe, config.set, config.describe{ text }, { description, argumentHint, isHidden }, { deny: reason }
Turnsturn.start, turn.step, turn.completeturn.step is an async generator — yield* next(e); it can swap model or effort per request
Sessionsession.start, session.end, session.compact, session.send, session.receive, session.append, session.attach, session.detach, session.measure{ skip: reason } on compact, { consumed: reason } on receive, rewritten rows on append
Subagentsagent.offer, agent.spawn{ isOffered: false } to withhold a type, { model } or { deny: reason }
Interfaceui.render, ui.resolve, ui.press, ui.input, ui.select, ui.focus, ui.scroll, ui.close, ui.message, ui.faultAn element tree, or next with changed props
Other modsplugin.register, engine.create{ refuse: reason } to stop another mod loading
Telemetrytelemetry.log, telemetry.markRequires the { to: 'collector' } matcher or validate fails; '*' does not match these

Two kinds of event are easy to miss:

  • classic.<Event> — every settings-hook event is also a mod event, so on('classic.Stop', …) or on('classic.PostToolUse', …) works and e is the same JSON a settings hook reads on stdin, transcript_path included. 'classic.*' matches all of them.
  • Every mods API method is itself an event, named without the $. — fs.read, model.complete, ui.open. A hook on one intercepts that call made by every mod that runs after it, and can return next(e), { deny: reason }, or { value }. That's the hook an org policy mod uses to audit or veto what other mods do.

What the first week shipped on top​

Mods landed on 2026-10-01 and the surface moved every two or three days after. If you wrote a mod in that first week, this is the list to re-read — each row is a changelog entry, with the version that introduced it.

Added inWhat
v2.1.289agent.spawn fires for agent-team teammates too, one agent id is shared across plugin hook events, and $.agent.list() reports idle and waiting states
v2.1.289A Client element that fails while drawn now fails alone instead of taking down everything around it, and raises ui.fault
v2.1.290turn.step's result carries serverToolUses — the tool calls the API ran itself (the advisor), each with id, name, input, start and end
v2.1.290tool.check gets agentId, so a hook can tell a subagent's permission check from the main session's, and ceiling, naming the approval an organization requires for that tool
v2.1.290ThemeKey and Color types in the hooks typings, so your editor lists the theme colors a drawing may name
v2.1.290claude plugin validate lists every hook registered at a gating site with whether it has a .catch — gatingHooks under --json. This is the audit for gotcha #1 below.
v2.1.292prompt.autocomplete, so a mod adds its own rows to the prompt box's autocomplete list
v2.1.292Prompt caching in $.model.complete: prompt and system take blocks of text, and cache: true on a block caches the request up to it
v2.1.292agent.spawn also fires for workflow agents, with their run and index, so a mod can refuse them
v2.1.292claude plugin install --marketplace <source> adds the marketplace if needed, under the same policy checks as claude plugin marketplace add, then installs from it

Two of these change how you write a hook rather than what it can see. cache: true on a $.model.complete block means a mod that classifies every prompt no longer re-pays for its own instructions each turn — the same economics as prompt caching in your own API calls. And gatingHooks in validate --json turns "does this guard fail open?" from a code review into a CI assertion.

Reading the real token numbers​

turn.step streams, so its hook is an async generator. This is the cheapest honest way to see your own prompt-cache hit rate per request:

on('turn.step', async function* ($, e, next) {
const result = yield* next(e)
if (result.usage) {
$.ui.log('cache read ' + result.usage.cache_read_input_tokens +
' · wrote ' + result.usage.cache_creation_input_tokens)
}
return result
})

result.usage carries input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens and the model that answered. The hook also fires for subagents' requests, so filter on e.agentId when you only want the main thread. And note the corollary for anyone rewriting prompt.section or prompt.context: text from those hooks that changes between requests invalidates the prompt cache.

The $ namespaces​

NamespaceMethods
$.pluginname, root
$.uiresolve, invalidate, open, close, panes, focus, scroll, toast, status, log, notice, ask, copy, selection, blit
$.command / $.tool / $.agentregister, run/call, list ($.tool also check)
$.modelcomplete, fork, classify
$.promptsubmit, read, fill, suggest, compose
$.turnabort
$.sessionmessages, cwd, root, model, turns, id, repo, surfaces, usage, version, compact, send, append, authorize
$.config / $.settings / $.envlist/set, read, get/set
$.fsread, write, list, exists, stat, ancestors
$.storeget, set, delete, keys — machine-wide, shared by every session
$.stateget, set, plus atom, read, update, derive, memberOf imported from claude-code
$.clock / $.http / $.process / $.mcpnow, sleep, after, every · fetch · run, spawn · call, connect
$.audio / $.telemetryplay, speak · log, mark

$.session.usage() is the one worth knowing by name: it returns { startedAt, context, rateLimits, cost }, where context has tokens, window and percent, and rateLimits is a list of { kind, percentUsed, resetsAt }. That's a plan-limit gauge in about four lines — see Context Budget Audit for what to do with the numbers.

Drawing​

A ui.render hook returns an element tree at a render site. Sites include Pane and AbovePrompt (your own real estate), plus the ones that let you restyle what Claude Code already draws: UserMessage, AssistantMessage, ToolUse, ToolResult, ToolGroup, CommandOutput, AskUserQuestion, Spinner, TurnDuration, InfoNotice, SessionMode, PromptHint, and terminal-only ToolProgress.

Elements are Box, Text, Button, Link, Code, Markdown, Input, Select, Client, plus Svg (Desktop only) and Raster / Image (terminal only). Fit the tree to its site by reading e.props.bodyColumns for width and e.props.scroll.bodyRows for height when e.props.placement is 'dock'.

Where your mod actually appears is not where its hooks run. This table is the one to check before you promise a teammate a pane:

Where you run Claude CodeHooks runDrawing appears
claude in a terminal (incl. editor terminals, JetBrains plugin)YesYes
Desktop app, Code tabYesYes, minus terminal-only elements
Desktop app, WSL sessionNo — plugins aren't available thereNo
VS Code extension chat panelYesNo
claude -p and the Agent SDKYesNo
Remote Control from claude.ai / mobileYes, in the session on your machineIn that terminal
Cloud sessionYes, if the plugin reaches itNo

So a mod that draws should check its surface and fall back to a transcript line or a command's text reply.

Six gotchas that bite in production​

Each of these is a documented behavior, not a bug — and each one has burned someone already.

1. A guard hook fails open. If a hook throws, times out, or returns the wrong shape before calling next, Claude Code skips it and the next handler runs in its place — meaning the command you were guarding runs. If it failed after next resolved, that result stands. To fail closed, attach a .catch:

on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
// next.error.kind is 'throw' or 'timeout'
return { deny: 'The command guard failed, so this command was not run: ' + next.error.kind }
})

2. The 10-second budget counts your awaits, not the API's. A hook gets 10s of its own execution time per event, excluding time inside next or inside a mods API call other than $.clock.sleep. So await $.ui.ask(...) can hold a tool call indefinitely while the user decides — but await myOwnPromise() burns the budget, and a hook that times out is skipped, so the held command runs. Read next.budget.remainingMs if you need to know where you stand.

3. Installed mods share one worker thread. A hook that blocks it — a loop that never awaits — gets your mod unloaded with it crashed the hooks worker. If the worker dies three times and Claude Code can't attribute the crashes to one mod, it unloads every non-built-in mod for the session, your org's included, until someone runs /reload-plugins.

4. State resets in three different ways. Module-level variables re-initialise on every reload (so calls goes back to 0 whenever you save). $.state resets to defaults after /clear, /resume or /branch — and session.start does not fire again, so reload saved values from a classic.SessionStart hook instead. Anything several sessions write belongs in $.store, not $.fs.write, because $.fs.write isn't atomic and another process can read a half-written file.

5. Your edits don't reach an installed copy. Claude Code caches an installed plugin by version, so develop against --plugin-dir and bump the manifest version before reinstalling.

6. Decide before you call next, not after. This one changed under people in October. A hook that calls next(e) and then returns a refusal used to be answered as a refusal. As of v2.1.292 a config.set, state.set, env.set or agent.spawn hook that denies after calling next(e) is reported as failed, by name — and v2.1.290 made the same change for a prompt.submit hook that drops a prompt after calling next(e), which until then was ignored silently. The rule to internalise: calling next is the commitment. Branch first, return { deny: reason } or { drop: reason } without ever calling next, and only call it on the path where you're letting the event through. (v2.1.292 also closed the related hole where a hook that called next(e) and then failed while the turn was interrupted let the call through; it is now rejected.)

Two smaller ones worth knowing: an unprompted $.ui.open needs 144 terminal columns the first time (110 once the user has opened that pane themselves), and $.session.messages() only returns the newest 4,096 entries.

Limits​

LimitValue
Hook execution time, one event10 s
.catch handler1 s
All session.end hooks together1.5 s
$.process.run timeout30 s default, 10 min max
$.model.complete maxTokens1024 default, up to 64,000 or the model's output cap
$.fs.read / $.fs.write, one file4 MiB
$.store, total4 MiB of JSON
$.session.messages()newest 4,096 entries
ui.render redrawsthrottled to 10/s (30/s in the terminal for the visible pane, expanded band, hint line)
One string child of Text10,000 characters
Code / Markdown text10,000 characters · Svg 131,072 · Image 2 MiB
Rastercolumns ≤ 512, rows ≤ 256
Command / tool / agent / pane namesletters, digits, _, -, ≤ 64 chars
One claude plugin test test5 s unless it sets timeoutMs

Security: what a mod actually reaches​

Mods are not sandboxed

A mod runs in Claude Code's process, with your permissions. Once loaded it can read and write any file your account can, start programs, make network requests, read your environment variables and settings files (including an API key kept in either), see every prompt and every tool call, rewrite them, submit a prompt as if you had typed it, approve a tool call before you're asked, and spend your plan or API credits via $.model.complete. If you enable sandboxing, it isolates the Bash commands Claude runs — a process a mod starts runs outside it.

There is one asymmetry that deserves its own line, because it defeats the most common mental model: deny permission rules bind Claude's tool calls, not a mod's own calls. With Read(.env) denied, a mod can still read that file with $.fs.read, or start a program that does. Permission rules are not a containment boundary for mod code. Nor can a mod restyle the permission prompt itself — that one surface is off-limits, so what a prompt shows you is always real.

The practical defense is the audit line, not trust: run claude plugin validate on the directory and read calls:. These are the entries that should make you stop and read the source — $.fs.read / $.fs.write, $.process.run / $.process.spawn, $.http.fetch, $.env.get / $.settings.read (API keys), $.env.set (changes what every later command and MCP server runs), $.model.complete (your credits), $.prompt.submit (can submit text as the user's own words), $.session.send (feeds another session's Claude). On the hooks: line, tool.call and prompt.submit mean it sees and can change everything; session.append means it can rewrite stored conversation rows; tool.check means it can allow or deny before any prompt appears.

Anthropic publishes sample mods in claude-code-playground — token-weather (context-window forecast above the prompt), blast-radius (holds a risky shell command and shows what it would change), replay-theater (a /replay command stepping through the turn's edits). Shared as-is, unsupported, and they run with your permissions like any other. Same review discipline as Reviewing Third-Party Code.

Turning them off​

ScopeHow
One modDisable or uninstall its plugin from the Installed tab in /plugin
Every installed mod, one sessionclaude --safe-mode (also disables your other customizations)
Every installed mod, always"disableAllHooks": true in ~/.claude/settings.json — your settings hooks and custom status line stop too

Built-in mods aren't affected by any of these; each has its own switch in /plugin. To test whether mods can load at all, run claude plugin test from a directory with no mod in it: no hooks module to load means yes; hooks modules are turned off here means a setting or org policy blocks them; hooks modules are turned off in this process means Anthropic has disabled installed mods remotely and nothing local re-enables them.

If you set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS during early access, remove it — v2.1.287+ ignores it at any value, so a 0 there does not keep mods off.

The org side​

On a machine with managed settings, or for a Team/Enterprise sign-in, Claude Code loads a built-in guard — sec-default@builtin, shown as cc-plugin-sec-default — ahead of every user-installed mod. By default it protects only what the org manages (managed hooks' inputs and decisions, the system prompt, managed instructions, settings as any mod reads them, managed MCP tools) and enforces that a user's mod can't approve a call a deny rule refuses. Everything else stays allowed. Its source is public in mods/sec-default.

Admin goalManaged setting
No user-installed mods, hooks untouchedallowManagedModsOnly: true under pluginConfigs → cc-plugin-sec-default@builtin → options
Only the org's modsthe above + install them so they count as the org's
Any mod from approved marketplaces onlykeep marketplace restrictions + disableSideloadFlags: true
Any mod, with the org's mod policing themlist the org's mod with sec-default@builtin in prependPlugins
Nothing at all, managed hooks includeddisableAllHooks: true

The guard reads its options only from managed settings, keyed only as cc-plugin-sec-default@builtin (prependPlugins also accepts the short sec-default@builtin; pluginConfigs doesn't), and it fails closed — if it can't read managed settings it refuses every user mod. If you set prependPlugins yourself, name the guard in the list or it stops loading.

A policy mod gets plugin.register for every mod about to load, with e.tier (prepend / user / append / builtin) and e.uses.calls — the same list validate prints, written without the $.. Returning { refuse: reason } stops that mod loading. Note the same fail-open trap as above: if your check throws, the mod it was checking loads, so put the check in a named function and add a .catch that refuses the user tier.

Debugging​

Where a failure line appears depends on the session, which is why "my mod does nothing" is so common:

  • Hot-reloading a plugin directory (--plugin-dir, or Claude-written mods with hot reload on): a dim line in the transcript.
  • Any other interactive session, including a mod installed from a marketplace: the debug log only — start with claude --debug.
  • claude -p --plugin-dir: stderr in the default text format; another mod's refusal goes to the debug log only.
claude --debug-file ./mod-debug.log --plugin-dir ./first-mod
tail -f ./mod-debug.log | grep first-mod

A loaded mod prints a line like hooks module first-mod@inline loaded (worker, environment 2, tier user); events: session.start,tool.call,command.run,ui.render. Mods loaded with --plugin-dir carry the @inline suffix. Write your own lines with $.ui.log('message', { to: 'debug' }) — without the second argument $.ui.log adds a dim transcript line instead. Messages worth recognising: hook skipped: + reason (threw, timed out, wrong shape), reload failed, the previous version stays loaded: (the last working version keeps running), ui.render (Pane) refused: (your tree didn't validate — the engine drew its own), and … registered /tally but no command.run hook answered it.

Mods vocabulary
Нажмите Enter или пробел, чтобы перевернуть карточку. Используйте стрелки влево и вправо для перехода между карточками.Показан термин.
1 / 8

Check yourself

0/5
  1. Your tool.call hook blocks risky Bash commands. It throws an unexpected TypeError before reaching next(e). What happens to the command?
  2. Why does `const ui = $.ui` make a mod fail to load?
  3. A deny rule in your settings refuses Read(.env). A mod you installed calls $.fs.read('.env'). What happens?
  4. You want a pane your mod draws to be visible in the VS Code extension's chat panel. Can it be?
  5. Which claim about the 10-second hook budget is correct?

Sources & further reading​

Next​