How to Build a Claude Code Mod: Files, Hooks, State, and Testing
A Claude Code mod is a plugin whose JavaScript or TypeScript hooks run inside your session. Ask Claude to write one, or create plugin.json, hooks/hooks.json, and a module that exports register. Load it with --plugin-dir, keep state in $.state, then check it with claude plugin validate and claude plugin test.
To build a Claude Code mod, you either describe it to Claude in a session and approve hot reloading, or create three files yourself: a plugin manifest, a hooks/hooks.json that names your code file, and a JavaScript or TypeScript hooks module that exports register(on). Start Claude Code with claude --plugin-dir pointed at the folder, and every save reloads the mod in place. (Create a mod)
This is the build manual. For the launch itself, see our Claude Code mods news coverage. The guide follows Anthropic's mods documentation, which wins wherever it differs from the getting-started tutorial on claude.dev.
Key Takeaways
- A mod is a plugin with a hooks module.
hooks/hooks.jsonlists one JavaScript or TypeScript file, and that file exportsregister(on, options). (Mods reference) - Version floor: In the terminal, use Claude Code v2.1.287 or later. Mods work in the Desktop app from v2.1.286. (Mods overview)
- Fastest path: ask Claude for the mod. It works from a built-in
plugin-authoringskill and writes the mod into a per-session folder that hot-reloads. - Every hook does one of three things: observe the event, rewrite it, or answer it so Claude Code's own behavior never runs.
- Keep state in
$.state, not in module variables, because each hot reload runs your module fresh. - Check before you ship:
claude plugin validatelists the events and API calls it finds in your code, andclaude plugin testruns your tests with no session. - Mods are not a permission sandbox. The module runs isolated, but everything it does through the mods API runs with your permissions.
Mods vs Settings Hooks vs Skills vs MCP Servers
Pick a mod when you need to draw in Claude Code's interface, add a command that runs your own code at once, or rewrite an event while it is in flight. A mod is the only one of the four that can draw in the interface, so if a settings hook, a skill, or an MCP server already covers the job, use that instead. (Mods overview)
| Mod | Settings hook | Skill | MCP server | |
|---|---|---|---|---|
| What it is | Functions in a plugin that run inside Claude Code's process | A shell command, HTTP request, or prompt run on a lifecycle event | A SKILL.md file of instructions | An external process or service that gives Claude tools |
| What it changes | Tool calls, prompts, commands, turns, and what the interface draws | Whether a tool call or prompt goes ahead, tool arguments and results, added context | What Claude knows and does | Which tools Claude has |
| Draws in the interface | Yes | No | No | No |
| You write | JavaScript or TypeScript | A script plus a settings.json entry | Markdown | A server in any language |
| Choose it when | You want a pane, a band above the prompt, a custom command, or to rewrite an event | You want to block, allow, or log an event with a script you already have | You keep pasting the same instructions | Claude needs to reach an external system |
The practical difference from a settings hook is lifetime. A settings hook runs a shell command for each event and passes JSON over stdin and stdout, while a mod is loaded once and stays in the session, so it can keep state, redraw as events happen, and call back into Claude Code. (Getting started with Claude Code mods) A plugin can hold all four, so one plugin can ship a mod beside a skill and an MCP server. If you need the server side, see our guide to building an MCP server.
Check Your Version and Where Mods Run
Mods are on by default. In the terminal, use Claude Code v2.1.287 or later, and run claude --version to check. The Desktop app includes its own copy of Claude Code, and mods work there from v2.1.286; enter /status in a local Code tab session to read its version. (Mods overview)
If you set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS during early access, remove it: Claude Code v2.1.287 and later ignores it, so setting it to zero no longer keeps mods off. (Mods overview)
A mod's hooks run in every kind of session that loads the plugin, but drawing is narrower: only the terminal and the Desktop app show a mod's panes, bands, and replaced rows. (Mods overview)
| Where you run Claude Code | Hooks run | What the mod draws appears |
|---|---|---|
claude in a terminal, including editor terminals and the JetBrains plugin | Yes | Yes |
| The Desktop app's Code tab | Yes | Yes, except terminal-only elements |
| A WSL session in the Desktop app | No, plugins aren't available there | No |
| The VS Code extension's chat panel | Yes | No |
claude -p and the Agent SDK | Yes | No |
| A cloud session | Yes, when the plugin reaches the cloud session | No |
A mod that draws can check which app it runs in and fall back to a transcript line or a command's text reply where nothing draws.
The Fastest Path: Ask Claude to Write the Mod
Describe the mod you want in an interactive Claude Code session, and Claude writes it. Claude works from a built-in skill named plugin-authoring, which tells it where to write the mod, which events and methods your version has, and how the mod gets loaded; you can also load the skill yourself by running /plugin-authoring. (Create a mod)
- Describe what you want to see, not the API. Ask in plain words, for example
show the current git branch above the prompt. The claude.dev tutorial makes the same point: its sample request only describes the display, and the skill covers state, validation, and which events to hook. - Approve each file. Claude writes the mod into the session's mods folder,
~/.claude/dev-mods/followed by the session's ID. In thedefaultandacceptEditspermission modes, Claude Code asks before Claude creates each file, because~/.claudeis a protected path. - Enable hot reloading. When Claude saves the first file, Claude Code asks whether to enable hot reloading for the session. Choose Enable for this session, and the mods in that folder load when the turn ends and reload after each turn that changes them.
- Confirm it loaded. Run
/pluginand open the Installed tab, which lists the mod and lets you turn it off.
A mod Claude writes loads only in the session that made it, and Claude Code deletes that session's mods folder once it's older than cleanupPeriodDays. (Create a mod) To keep one, copy its directory somewhere of your own, such as ~/mods/git-branch, then load it with claude --plugin-dir ~/mods/git-branch or add it to a marketplace.
A mod Claude writes also won't load where nobody can approve it, such as a claude -p run or dontAsk mode, in a workspace you haven't trusted, or when you started with --safe-mode or --bare or set disableAllHooks. Start here even if you plan to write mods by hand: read the code it produced, then run the validator on it.
Build a Mod by Hand: The Three Files
A mod needs three files: the plugin manifest at .claude-plugin/plugin.json, a hooks/hooks.json whose modules array holds one path to your code, and the hooks module itself. Mods add no required manifest fields, and the module can be named .js, .mjs, .cjs, .jsx, .ts, .mts, .cts, or .tsx, written as an ES module. You don't need Node.js, a bundler, or a build step, because Claude Code loads .js and .ts files directly. (Mods reference; Create a mod)
This example, edit-count, counts the Edit and Write calls Claude makes and shows the count in the band above the prompt. The layout:
edit-count/.claude-plugin/plugin.json
edit-count/hooks/hooks.json
edit-count/hooks/edit-count.js
edit-count/types/index.d.ts
The manifest names the plugin and points types at the file that declares the mod's state:
{
"name": "edit-count",
"version": "0.1.0",
"description": "Counts Claude's file edits and shows the count above the prompt",
"author": { "name": "Your Name" },
"types": "./types/index.d.ts"
}
hooks/hooks.json lists the module, with a path relative to hooks.json:
{
"modules": ["./edit-count.js"]
}
types/index.d.ts declares the one value the mod keeps in $.state:
declare module 'claude-code' {
interface PluginState {
'edit-count': { edits: number }
}
}
The hooks module registers two hooks. Claude Code calls register once when the mod loads, and each call to on adds a hook for the event it names:
// hooks/edit-count.js
import { atom, read, update } from 'claude-code'
// Held by Claude Code for the session, so it survives a hot reload
const edits = atom({ plugin: 'edit-count', key: 'edits' }, 0)
export function register(on) {
// Observe: let each Edit or Write run, then count it
on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
const result = await next(e)
await update($, edits, (n) => n + 1)
return result
})
// Draw: one line in the band above the prompt
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const n = await read($, edits)
if (n === 0 || e.props.hasSurvey) return next(e)
const { Box, Text } = $.ui.resolve(e)
return Box({
paddingX: 1,
children: [Text({ color: 'cyan', children: 'Files edited this session: ' + n })],
})
})
}
Load it for one session, then ask Claude to edit a file:
claude --version # 2.1.287 or later
claude --plugin-dir ./edit-count
Three details in that module matter in every mod. $.ui.resolve(e) returns the element constructors for the surface being drawn, because the terminal and the Desktop app support slightly different sets. The band's props, such as hasSurvey and bodyColumns, live on e.props, not on e. And returning next(e) when you have nothing to draw hands the band back to Claude Code and to other mods. (Getting started with Claude Code mods)
How Hooks Work: Observe, Rewrite, or Answer
Every hook receives the same three arguments: the mods API as $, the event as e, and the next handler as next. The handlers for an event form a middleware chain, so next(e) passes the event to the next mod's hook and, at the end of the chain, to Claude Code's own behavior. What your hook does with next decides what it is. (React to events)
| Move | Code shape | Use it to |
|---|---|---|
| Observe | const r = await next(e); return r | Count, log, or take a reading without changing anything |
| Rewrite | return next({ ...e, text: e.text.trim() }) | Change what later mods and Claude Code receive |
| Answer | return { deny: 'reason' } without calling next | Refuse a tool call, or serve a command yourself |
The event is deeply frozen, so to change it you pass a modified copy to next. (Mods reference) The second argument to on is an optional matcher: a value, an array of allowed values, or a regular expression per field, as in { tool: ['Edit', 'Write'] } above. The events cover tool calls, submitted prompts, turns starting and finishing, the session starting and ending, slash commands, and ui.render for each part of the interface as it's drawn.
When several mods hook the same event, the first mod in the chain is outermost: it sees the event before the others and the result after them, and it decides whether the others run at all. Mods your organization lists in prependPlugins run before the mods you install, and Claude Code's other built-in mods run last. (React to events)
Keep State Across Hot Reloads
Keep any value a drawing depends on in $.state, because a module variable starts over every time the module reloads, which happens on every save during development. $.state holds values for the whole session and survives a reload; $.store keeps values from one session to the next. (Draw in the interface)
| Keep it in | It lasts until | Use it for |
|---|---|---|
| A module variable | The module reloads | Values you can lose, such as a selected tab |
$.state | The session ends, or you run /clear, /resume, or /branch | Values a drawing reads that should survive a reload |
$.store | Your mod deletes it, or no session uses the store for cleanupPeriodDays | Settings and history the user expects next time |
$.state is reactive. A ui.render hook that reads a value subscribes to it, so Claude Code redraws that site each time you write the value, and you never call $.ui.invalidate. A render hook can read state but can't write it, so write from a button callback or another event's hook, as edit-count does from tool.call. Every value must be declared under PluginState in the file the manifest's types field names, or validation fails. (Draw in the interface)
$.store is a key-value store that every session on the machine shares, saved as a JSON file of your plugin's own under ~/.claude/plugins/store/, with a limit of 4 MiB of JSON in total. (Mods reference) If you copy a stored value into $.state at session start, copy it again after /clear, /resume, or /branch, because those reset $.state and session.start doesn't fire again.
Validate and Test Before You Share
Run claude plugin validate on the mod's directory before anyone else loads it. It checks the manifest and runs the same static analysis on the hooks module that Claude Code runs at load time, then prints a hooks: line with the events you handle and a calls: line with every mods API method you call. A misspelled event name shows up as an error. You also get one line for each hook that can refuse an action, such as gating hook without .catch: tool.call, which tells you whether that hook has a .catch error handler. (Create a mod)
claude plugin validate ./edit-count
claude plugin test ./edit-count
The static analysis sets four rules your code must follow:
- Write each mods API call in full, as in
$.store.get('notes'). Assigning$.uito a variable or destructuring it fails validation. - Write each event name as a string literal. A variable or a loop over event names fails.
- Use ES module
importdeclarations at the top of the file. A dynamicimport()fails validation. Write every file withimport, notrequire. - Import only your own files by relative path. The one bare import allowed is
claude-code, for types and a few helpers.
claude plugin test runs every file ending in .test.ts or .test.tsx, with no session, sign-in, or network. (Create a mod) Hooks that a test registers run after your mod in the chain and stand in for Claude Code, so you control what a tool call or $.session.usage() returns. A test for edit-count:
// tests/edit-count.test.ts
import { expect, test } from 'claude-code/testing'
test('the band counts Edit and Write calls', async ($, on) => {
// Answer each tool call in Claude Code's place, so no tool runs
on('tool.call', () => ({ result: 'ok' }))
await $.tool.call({ tool: 'Edit', file_path: 'a.ts' } as any)
await $.tool.call({ tool: 'Read', file_path: 'a.ts' } as any)
const ui = await $.ui.mount({
plugin: 'edit-count',
surface: 'terminal',
component: 'AbovePrompt',
props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 120 },
} as any)
expect(await ui.find({ type: 'Text', text: /edited this session: 1/ })).toBeDefined()
await ui.unmount()
})
For type checking, lean on the declarations Claude Code writes into .claude-plugin/types/ each time it loads your mod from --plugin-dir. They describe the events, methods, and elements in the exact version you run, and if your mod has no tsconfig.json, Claude Code adds one so tsc -p works. The docs tell you to trust these files over any web page when they disagree. (Create a mod) For scoring a whole plugin's effect on Claude's output, see our coverage of Claude Code plugin evals.
Install and Share Your Mod
A mod is a plugin, so you share it the way you share any plugin: list it in a marketplace, which can be a GitHub repository with a .claude-plugin/marketplace.json file. People add the marketplace, then install with /plugin install your-mod@your-marketplace in a session or claude plugin install your-mod@your-marketplace in their shell. (Mods overview)
/plugin marketplace add your-org/my-mods
/plugin install edit-count@my-mods
/reload-plugins
Someone who installs from the shell while a session is open runs /reload-plugins in that session to load the mod; otherwise it loads the next time Claude Code starts. (Mods overview) To reach people without sending a link, submit the plugin to Anthropic's directory from the developer portal at claude.ai/directory/manage, covered in our plugin directory submission guide. (Publish and distribute a plugin)
Two release gotchas catch most first-time authors. claude plugin validate fails a plugin name that looks like one of Anthropic's own, such as one that starts with claude-. And Claude Code caches an installed plugin by version, so your edits don't reach the installed copy until you increment the version and install again; keep developing against the directory with --plugin-dir. (Create a mod) Say in your README which Claude Code version you tested with, because events and methods can change between releases.
Three Example Mods Worth Copying
Anthropic publishes three sample mods in the claude-code/mods directory of claude-code-playground, each a complete plugin whose README explains how it was built. Read the one that matches what you want to build before you start. (Mods overview)
| Mod | What it does | Pattern it teaches |
|---|---|---|
| Token Weather | Draws a forecast of how full the context window is in the band above the prompt | Reading $.session.usage() after each turn and keeping history in $.state |
| Blast Radius | Holds a risky Bash command, such as rm -rf or a force push, shows what it would change, and offers Proceed and Cancel buttons | The answer move, dry runs with $.process.run, buttons with hotkeys, and falling back to the band when a pane can't be placed |
| Replay Theater | Records each Edit and Write call in a turn and adds a /replay command that steps through the diffs | Pairing turn.start with turn.complete, and registering a command with $.command.register |
Blast Radius comes with its own warning, and it applies to every guard you write: it's a safety net, not a permission system. It reads the command text, so command substitution, aliases, and scripts that call rm get past it, and the tutorial says to use permission rules for a hard block. (Getting started with Claude Code mods) Token Weather pairs well with our guide to Claude Code context management.
To try a sample, clone the repository and start Claude Code with --plugin-dir pointed at the mod's directory. For production-grade examples, read the built-in mods in the mods directory of the Claude Code repository: the /diff pane, the guard sec-default, and the mod that loads AGENTS.md, which we covered in Claude Code's AGENTS.md support.
Limits and Gotchas
Claude Code skips a hook that exceeds its time limit. A hook gets 10 seconds of its own execution time per event, or 50 milliseconds for a prompt.edit hook, and time spent inside next or inside a mods API call doesn't count, except for $.clock.sleep. (Mods reference) To hold a tool call while the user decides, wait inside a mods API call such as $.ui.ask, which shows your question with numbered options and resolves to the label the user picks. (React to events)
- Processes:
$.process.runtimes out after 30 seconds by default, and 10 minutes at most. Pass arguments as an argv array so nothing in a path runs as shell code. - Files:
$.fs.readand$.fs.writehandle up to 4 MiB for one file. - Panes: a pane opened without the user asking is placed from 144 terminal columns, or 110 after the user has opened it once. Below that,
$.ui.openresolves withisPlacedset to false, so draw the same content in the band instead. (Mods reference) - One
session.starthook: callingontwice for the same event with no matcher keeps the module from loading, so put all your startup work in one hook. - Terminal symbols: use single-width symbols rather than emoji, so a band lines up in every terminal font.
- Debugging: start with
claude --debugand look for a line saying a hook returned a tree that does not validate. (Getting started with Claude Code mods)
Security: What a Mod Can Reach and How to Turn Mods Off
The hooks module runs in an isolated runtime with no DOM and no Node, so it reaches nothing outside itself except through the mods API. That isolation is not a permission sandbox. Through the API, a mod acts on your machine as you: it can read and write files anywhere your account can, start programs, make network requests, read your environment variables and settings files, see every prompt and tool call, and approve a tool call before you're asked. (Mods overview)
The claude.dev tutorial describes the runtime as a sandbox of its own, while the docs state that mods aren't sandboxed; both are true in different senses, and the docs' meaning is the one that matters for trust. Turning on Claude Code's sandboxing doesn't change this, because the sandbox isolates the Bash commands Claude runs, and a process a mod starts runs outside it. A mod that approves tool calls can approve one that an ask rule would prompt for, or that one of your own PreToolUse hooks blocked. A mod can restyle much of the interface, but not the permission prompt. (Mods overview)
Before you install someone else's mod, clone it and run claude plugin validate on its directory: the hooks: and calls: lines show what it handles and what it asks Claude Code to do, without running it. To stop mods:
| To stop | Do this |
|---|---|
| One mod | Disable or uninstall its plugin in the Installed tab of /plugin |
| Every installed mod, for one session | Start Claude Code with --safe-mode, which also disables your other customizations |
| Every installed mod, in every session | Set "disableAllHooks": true in ~/.claude/settings.json, which also stops your settings hooks and custom status line |
Neither --safe-mode nor disableAllHooks stops the mods built into Claude Code, such as the /diff pane; most of those you disable one at a time in /plugin. For managed teams, the built-in guard sec-default loads ahead of every mod a person installs on a machine with managed settings, or for a user signed in with a Team or Enterprise plan. (Mods reference) For more on plugin trust, see Claude Code's security-guidance plugin.
Sources
- Anthropic, "Mods overview," Claude Code docs: https://code.claude.com/docs/en/plugins/mods/overview
- Anthropic, "Create a mod," Claude Code docs: https://code.claude.com/docs/en/plugins/mods/create
- Anthropic, "Mods reference," Claude Code docs (as of v2.1.290): https://code.claude.com/docs/en/plugins/mods/reference
- Anthropic, "React to events with a mod," Claude Code docs: https://code.claude.com/docs/en/plugins/mods/events
- Anthropic, "Draw in the interface with a mod," Claude Code docs: https://code.claude.com/docs/en/plugins/mods/interface
- Anthropic, "Publish and distribute a plugin," Claude Code docs: https://code.claude.com/docs/en/plugins/publish
- Addy Osmani, "Getting started with Claude Code mods," claude.dev blog (October 1, 2026): https://claude.dev/blog/getting-started-with-claude-code-mods/
- Anthropic, sample mods in claude-code-playground: https://github.com/anthropics/claude-code-playground/tree/main/claude-code/mods
- Anthropic, built-in mods in the Claude Code repository: https://github.com/anthropics/claude-code/tree/main/mods
Read next
More skills and workflows- Claude Code
Claude Code Mods Let Developers Customize the CLI and Desktop App
Anthropic introduced Claude Code mods as a plugin-based way to customize how Claude Code behaves and looks. Small JavaScript or TypeScript modules can add interface elements, change tool calls, or replace built-in features. Mods run with Claude Code's access to your machine, so review their source before installing.
- Claude Code
Claude Code Adds Plugin Evals for Regression Testing and No-Plugin Baselines
Claude Code now includes `claude plugin eval`, a testing workflow for plugin and skill authors. The command can create cases and graders, run a plugin in isolated non-interactive sessions, compare results with a no-plugin baseline, generate an HTML report, and support CI gates for plugin changes. Evals require Claude Code v2.1.269 or later and consume model usage.
- Claude
Claude Opens a Plugin Directory Submission Portal for MCP and Skills
Anthropic launched a Claude directory submission portal on September 25, 2026. Developers on paid Claude plans can submit a remote MCP connector or a GitHub-hosted plugin bundle that packages MCP servers and skills, follow validation and safety review, publish approved versions, and see listing and usage analytics.
How to Build Your Own MCP Server: A Beginner's Guide
To build an MCP server in TypeScript, install the v2 SDK package @modelcontextprotocol/server with Zod v4, register each tool with server.registerTool and a Zod input schema, serve it over stdio, then add it to Claude Code with claude mcp add or a .mcp.json entry. A one-tool server is about 50 lines.
Frequently Asked Questions
What is a Claude Code mod?
A mod is a Claude Code plugin with a hooks module: a JavaScript or TypeScript file whose functions Claude Code calls when events happen, such as a tool call, a submitted prompt, or part of the interface being drawn. Each function can observe the event, rewrite it, or answer it itself.
Which Claude Code version do mods need?
Use Claude Code v2.1.287 or later in the terminal, where mods are on by default and need no flag. The Desktop app bundles its own copy of Claude Code, and mods work in its Code tab from v2.1.286. Check with claude --version in your shell, or /status in the Desktop Code tab.
Are Claude Code mods sandboxed?
No. The hooks module runs in an isolated runtime with no DOM and no Node, so it reaches the outside world only through the mods API. That API acts with your permissions: a mod can read and write your files, start processes, and make network requests. Install mods only from authors you trust.
Do I need Node.js or a build step to write a mod?
No. Claude Code loads .js and .ts hooks modules directly, so you need no Node.js install, bundler, or build step. Write every file as an ES module with import declarations at the top, and import only your own files by relative path, plus the claude-code module for types and helpers.
How do I keep a mod that Claude built for me?
Copy its folder out of ~/.claude/dev-mods/ into a directory of your own. A mod Claude writes loads only in the session that made it, and Claude Code deletes that session's mods folder once it is older than cleanupPeriodDays. Load the copy with claude --plugin-dir, or add it to a marketplace.
Can a Claude Code mod block a dangerous command?
Yes. A tool.call hook can refuse the call by returning a deny result without calling next, or hold it while it asks you. Treat that as a safety net, not a permission system: a guard that reads command text misses aliases and scripts. Use permission rules for a hard block.