Claude Code just got a mod menu. On October 1, 2026, @ClaudeDevs posted "You can now mod Claude Code" (around 2.6M views at the time we checked): change how it behaves, customize the UI, swap in your own features, with a few lines of TypeScript, or by having Claude build the mod for you. Mods ship inside plugins, so you install them with /plugin in the CLI or the desktop app. We read the new mods docs and turned them into diagrams, verbatim code and a security checklist. Facts are Anthropic's unless we say otherwise; the jokes are ours.
TL;DR
- A mod is a plugin made of JavaScript or TypeScript event handlers that run inside Claude Code, not as an outside script.
- Each handler can observe an event, rewrite it, or answer it outright (so the tool never runs).
- Mods can draw: a pane, a band above the prompt, a status line, toasts, and restyled built-in pieces like the spinner. Not the permission prompt.
- Needs Claude Code v2.1.287 or later, where mods are on by default. Review any mod with
claude plugin validatebefore you install it. - Mods are not sandboxed. A mod runs with your permissions and can approve a tool call before you're asked. Install like you'd run a stranger's script: carefully. 🫡
What dropped
The requirement is short: Claude Code v2.1.287 or later, and mods are on by default. If you were in early access, the CLAUDE_CODE_ENABLE_FUNCTION_HOOKS flag is now ignored. Mods work in the CLI and in the Code tab of Claude Desktop. Installing one looks like any other plugin:
/plugin install token-chart@your-org
# or, from your shell
claude plugin install token-chart@your-org
# installed from the shell mid-session? then run
/reload-pluginsOnce something is loaded, /plugin shows a dim line like 1 mod active · first-mod. Runtime Wire's Ryan Merket flagged the headline risk on launch day: mods are unsandboxed and run with full machine privileges. More on that below.
What a mod actually is
Anthropic's one-liner: "A mod is a plugin that changes how Claude Code looks and behaves." Under the hood it's a set of event handlers. When something happens (a tool call, a submitted prompt, part of the interface being drawn), Claude Code calls your handler, and your handler can watch, change, or take over. The big difference from the hooks you already know: settings hooks (a shell command, HTTP call or prompt in your settings file) run outside Claude Code. A mod's handlers are functions that run inside it.
- 01
Event fires
e.g. tool.call: Claude is about to run Bash.
- 02
Your hook runs
It gets ($, e, next): the mods API, the event, and the rest of the chain.
- 03
Pick a move
Observe: return next(e). Rewrite: call next with a changed copy. Answer: return a result without next.
- 04
next() passes it on
Guard and org mods first, then mods you install, then appended and built-in mods.
- 05
Claude Code acts
Unless a hook answered first, in which case the tool never runs.
One more rule makes mods reviewable: to do anything outside its own code (draw, add a command, call a model, read a file, start a process, hit the network), a mod has to go through the mods API, $. That's why Claude Code can list what a mod does before you install it. In harness engineering terms, a mod is harness customisation that runs inside the agent itself; our harness vs loop vs graph breakdown helps you pick which layer to mod first.
Your first mod: count tool calls
The docs' starter mod adds a /tally command and shows a live tool-call count in the spinner. No Node.js install, no bundler, no build step: Claude Code loads .js and .ts files directly. Three files:
first-mod/
├── .claude-plugin/
│ └── plugin.json
└── hooks/
├── hooks.json
└── register.js{
"description": "The first-mod hooks module",
"modules": ["./register.js"]
}// The count, shared by the hooks below
let calls = 0
// Claude Code calls this once when the mod loads
export function register(on) {
// Runs when the session starts, before your first prompt
on('session.start', async ($, e, next) => {
// Add the /tally command
await $.command.register({
name: 'tally',
description: 'Show how many tool calls Claude has made',
})
// Let the session start as usual
return next(e)
})
// Runs each time Claude is about to use a tool
on('tool.call', async ($, e, next) => {
calls += 1
// Ask Claude Code to draw the interface again, so the new count shows
$.ui.invalidate('ui.render')
// Let the tool run as usual
return next(e)
})
// Runs when you type /tally, and only then, because of the matcher
on('command.run', { command: 'tally' }, async () => {
// The text to print in the transcript
return { text: 'Claude has made ' + calls + ' tool calls since this mod loaded' }
})
// 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 with claude --plugin-dir ./first-mod. The spinner now reads like Thinking · tool calls: 2…, and /tally prints first-mod: Claude has made 2 tool calls since this mod loaded. It hot-reloads on save (each reload resets the count to 0, because module variables are lost on reload). Notice the module variable calls: that's how hooks share data with each other.
first-mod, live
01SPINNER
Thinking · tool calls: 1…
Claude reaches for its first tool.
02SPINNER
Thinking · tool calls: 2…
tool.call fires, the count goes up, the UI redraws.
03SPINNER
Thinking · tool calls: 3…
Still Claude's spinner, just with a suffix.
04/tally
first-mod: Claude has made 3 tool calls since this mod loaded
No Claude turn needed. Just your code. 😌
Dramatised from the docs' first-mod example (the docs show 2 tool calls; we let it run to 3).
Before you trust any mod, including this one, run the validator. It statically lists every hook and every API call:
❯ ./register.js hooks: session.start, tool.call, command.run{command=tally}, ui.render{component=Spinner}
❯ ./register.js calls: $.command.register, $.ui.invalidate
✔ Validation passedTo keep that analysis honest, write every $ call in full and event names as string literals. Claude Code also writes .d.ts types into .claude-plugin/types/ (the docs say trust those over any page), and claude plugin test runs *.test.ts files with no session, sign-in or network.
Or let Claude write it
Too lazy for three files? Valid. Claude Code has a built-in plugin-authoring skill. Ask something like "make a mod that shows the current git branch above the prompt" and Claude writes it to ~/.claude/dev-mods/<session-id>/<name>/, then Claude Code asks whether to enable hot reloading for this session ("Enable for this session" or "Not now"). Mods Claude writes load only in that session; to keep one, copy it out and run it with claude --plugin-dir ~/mods/git-branch.
Where mods can draw
This is the part no settings hook can touch. A mod gets UI elements (Box, Text, Button, Link, Code, Markdown, Input, Select and more) from $.ui.resolve(e), or writes JSX in .tsx/.jsx files. Here's the screen map from the interface docs:
Your mod
Draws through $.ui; every pixel goes through the API.
Pane
A sidebar on the right in a wide fullscreen terminal; a framed region above the prompt otherwise.
AbovePrompt band
A band above the prompt, shared by every mod.
Status · toast · log
$.ui.status line under the prompt, $.ui.toast top right, $.ui.log as a dim transcript line Claude doesn't read.
Built-in sites
Spinner, ToolUse, ToolResult, messages, AskUserQuestion, PromptHint, TurnDuration and more.
Off-limits: the permission prompt
It is not a render site. A mod can restyle much of the UI, but not that.
State lives in a module variable (lost on reload), $.state (per session, reactive) or $.store (persists, 4 MiB). Gotcha: a pane a mod opens by itself only shows in a terminal at least 144 columns wide (110 once you've opened it yourself).
Guard rails you can build
The events docs ship three guard patterns. All verbatim, all on the tool.call / tool.check events. First, the hard no:
// The matcher limits the hook to Bash calls, so e.command is the shell command
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (/git push .*--force/.test(e.command)) {
// Returning without calling next answers the event, so the command never runs
return { deny: 'Force pushes are not allowed in this repository. Push to a new branch instead.' }
}
// Every other command goes on to the permission check and then to Bash
return next(e)
})Second, hold the call and ask a human. The tool call literally waits on your answer:
const RISKY = /\brm\s+-rf?\b|\bgit\s+reset\s+--hard\b|\bgit\s+push\b.*--force/
export function register(on) {
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
// Let every other command through without a question
if (!RISKY.test(e.command)) return next(e)
// Start from the safe answer, so a question nobody answers refuses the command
let answer = 'Refuse'
try {
// The tool call waits here until the user picks one of the two labels
answer = await $.ui.ask('Run this command? ' + e.command, ['Run it', 'Refuse'])
} catch {
// The user dismissed the question, or this is a claude -p run with nobody to ask
}
if (answer !== 'Run it') {
// Answer without calling next, so the command doesn't run
return { deny: 'The user declined this command. Ask before trying a different approach.' }
}
return next(e)
})
}Third, tool.check: change what the permission check decided. This one denies git push while you're on main:
on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
// What the permission rules and settings hooks decided: 'allow', 'ask', or 'deny'
const decided = await next(e)
if (!e.input.command.includes('git push')) return decided
const branch = await $.process.run(['git', 'branch', '--show-current'])
if (branch.stdout.trim() !== 'main') return decided
return { decision: 'deny', reason: 'Push from a branch other than main' }
})The same page also has a prompt.submit hook that adds your git branch to context when a prompt mentions a PR, and a turn.step hook that logs prompt-cache usage (turn.step can even route a request to a different model). One catch: a hook that throws is skipped (fails open) unless you add .catch(...). For a guard, fail closed.
Mods vs settings hooks vs skills vs MCP
| Mod | Settings hook | Skill | MCP server | |
|---|---|---|---|---|
| What it is | Functions in a plugin that Claude Code calls in its own process | Shell command, HTTP call or prompt run on a lifecycle event | SKILL.md instructions Claude reads | External process that gives Claude tools |
| What it can change | Tool calls, prompts, commands, turns, what the interface draws | Whether a tool call or prompt goes ahead, args and results, context | What Claude knows and does | Which tools Claude has |
| Draws in the interface | Yes | No | No | No |
| You write | JS or TS | A script + a settings.json entry | Markdown | A server in any language |
| Pick it when | You want a pane, a band, a custom command, or to rewrite an event | You want to block, allow or log with a script you already have | You keep pasting the same instructions | Claude needs an external system |
Where mods run
| Where | Hooks run | Drawing appears |
|---|---|---|
| claude in a terminal (incl. editor terminals, JetBrains plugin) | Yes | Yes |
| Claude Desktop Code tab (not WSL sessions) | Yes | Yes, except terminal-only elements |
| WSL session in Desktop | No (plugins not available) | No |
| VS Code extension chat panel | Yes | No |
| claude -p and the Agent SDK | Yes | No |
| Remote Control from claude.ai or mobile | Yes, on your machine | In the terminal on your machine |
| Cloud session | Yes, for a plugin that reaches it | No |
Guard rails work almost everywhere, headless included; the UI fun is terminal and Desktop only.
Security: read this before you install anything
- Hook time per event
- 10 sTime inside next or API calls isn't counted, except $.clock.sleep.
- .catch handler
- 1 sAll session.end hooks together get 1.5 s.
- $.process.run
- 30 s → 10 min30 seconds by default, 10 minutes max.
- $.model.complete
- 1,024 tokensDefault maxTokens; up to 64,000 or the model's limit.
- Storage
- 4 MiBPer file for $.fs.read/write; total for $.store.
- Redraws
- 10/s30/s in a terminal for a visible pane, an expanded band or the hint line.
Also from the reference: toasts last 4 seconds by default, and names are letters, digits, _ and -, up to 64 characters.
Team and enterprise controls
Org admins get a built-in guard, sec-default@builtin. It loads ahead of user mods when the machine has managed settings or a Team or Enterprise sign-in, protecting managed hooks, the system prompt, managed CLAUDE.md, settings reads and managed MCP tools. There, deny rules beat user mods, and managed PreToolUse hooks run first with a final block.
01Only org-approved mods (plus built-ins) should load?
YES → allowManagedModsOnly, an option on the sec-default guardNO ↓
02Block --plugin-dir, --plugin-url and mods Claude writes?
YES → disableSideloadFlagsNO ↓
03Org mods must run before or after user mods?
YES → prependPlugins / appendPluginsNO ↓
04User mods should be able to override deny rules?
YES → allowModsToOverrideDenyRules (otherwise deny rules win)NO ↓
05No hooks except managed ones, or none at all?
YES → allowManagedHooksOnly (wider), disableAllHooks (widest)NO ↓
Leave the defaults: the sec-default guard already loads ahead of user mods on managed or Team/Enterprise machines
For finer policy, write a policy mod: the plugin.register event shows each mod's e.uses.calls and e.tier, and returning { refuse: reason } keeps that mod from loading. Mods that review mods. Very meta.
How other coding agents do hooks
Hooks aren't new; Codex, Gemini CLI and Cursor all have them. What differs is where the code runs and whether it can touch the UI. Here's each tool as its own docs describe it today (we checked all four on October 2, 2026):
| Tool | How hooks run | Where they're configured | Example events | UI drawing |
|---|---|---|---|---|
| Claude Code mods | JS/TS functions inside Claude Code's process | A plugin (hooks/hooks.json) | tool.call, tool.check, prompt.submit, ui.render | Yes: pane, band, status line, toasts, built-in sites |
| OpenAI Codex CLI | Command scripts or MCP tool calls, returning JSON | hooks.json or config.toml in ~/.codex or <repo>/.codex; plugins can bundle hooks | SessionStart, PreToolUse, PermissionRequest, PostToolUse, Stop | None described |
| Google Gemini CLI | Scripts or programs over stdin/stdout JSON; on by default since v0.26.0 | settings.json; extensions can bundle hooks | BeforeAgent, BeforeModel, BeforeTool, AfterTool, PreCompress | None described |
| Cursor | Processes over stdio JSON, command- or prompt-based | .cursor/hooks.json or ~/.cursor/hooks.json | beforeShellExecution, preToolUse, afterFileEdit, stop | None described (returns text messages only) |
As those docs describe things today, the others run external commands or processes that answer yes, no or "here's some context". Claude Code's mods add in-process functions plus a UI surface: more power to build with, and more power for a bad mod to abuse.
Built-in mods and the official samples
Fun fact: you're already running mods. Open /plugin → Installed → Built-in and you'll see them. Even /diff is a mod now (Runtime Wire noticed too). Note that disableAllHooks, --bare and --safe-mode don't stop built-in mods.
The list: cc-plugin-agents-md (loads AGENTS.md as project instructions), cc-plugin-diff (/diff and its pane), cc-plugin-plugin-authoring (the plugin-authoring skill, no mod code), cc-plugin-sec-default (guards what your org manages), cc-plugin-telemetry (analytics records) and cc-plugin-you-should-know (a side agent that watches your back on longer tasks and leaves a note above the prompt; off by default).
Anthropic also put three sample mods in the claude-code-playground repo, shared as-is with no support: token-weather (a forecast of your context window above the prompt), blast-radius (holds a risky shell command like rm -rf, a force push, git reset --hard or a migration, shows what it would change, with Proceed and Cancel buttons) and replay-theater (adds /replay to step through the file edits Claude made last turn, one diff at a time).
Two starter mods to steal (as sketches)
Two mods that earn their spot on day one. These are our sketches against the API shapes in Anthropic's docs, not code from the docs, and we haven't run them.
🎧 Vibe-coder lane
illustrative sketch · untested
A context meter in the AbovePrompt band, so you see the window filling before the vibes run out.
Uses $.session.usage(), which the API docs say returns context tokens, window and percent, plus a Text element from $.ui.resolve(e).
// Illustrative sketch, not from Anthropic's docs. Untested.
// Check .claude-plugin/types/ for the exact render return shape.
export function register(on) {
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
// The band is shared by every mod — check the types; you may want to compose with await next(e)
const usage = await $.session.usage()
const { Text } = $.ui.resolve(e)
return Text({ children: ['context ', usage.context.percent + '% of ' + usage.context.window] })
})
}🛡️ Team-lead lane
illustrative sketch · untested
A blast-radius-style hold: the docs' $.ui.ask pattern, with the regex widened to catch git push -f and migrations.
Proceed or Cancel, a log line when someone proceeds, and a safe default if nobody answers.
// Illustrative sketch, not from Anthropic's docs. Untested.
const RISKY = /\brm\s+-rf?\b|\bgit\s+reset\s+--hard\b|\bgit\s+push\b.*(--force|\s-f\b)|\bmigrate\b/
export function register(on) {
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (!RISKY.test(e.command)) return next(e)
let answer = 'Cancel'
try {
answer = await $.ui.ask('Blast radius check: ' + e.command, ['Proceed', 'Cancel'])
} catch {}
if (answer !== 'Proceed') return { deny: 'Held by the blast-radius check. Ask the user first.' }
$.ui.log('proceeded: ' + e.command)
return next(e)
})
}Both panels are ByteVancer's illustrative sketches, not code from Anthropic's docs, and untested. For the real thing, start from the official token-weather and blast-radius samples.
TL;DR of the TL;DR: mods turn Claude Code from an app you configure into an app you program. Huge for guard rails and dashboards, and a fresh supply-chain surface next to your secrets. Validate before installing, fail closed, keep real protection on your Git host. Then make the spinner say something unhinged. 🫡
