Claude Code Mods Tutorial: Build and Test Your First Mod, Plus 15 Community Examples
Updated on

A Claude Code mod is a plugin made of JavaScript or TypeScript functions that run inside Claude Code. A mod can draw a pane or a row above the prompt, add a slash command that runs instantly, and stop or rewrite a tool call before it runs. Mods shipped in Claude Code 2.1.287 on October 1, 2026, and they are on by default.
Pick the fastest way in:
| You want to | Do this |
|---|---|
| Get a mod without writing code | Describe it in a session, for example "make a mod that shows how long each turn took". Claude writes it and asks once to turn on hot reloading |
| Install someone else's mod | /plugin install <name>@<marketplace> in a terminal session, or claude plugin install <name>@<marketplace> in your shell |
| Load a folder while you build | claude --plugin-dir ./my-mod |
| Try the built-in side agent | /plugin enable cc-plugin-you-should-know@builtin |
Check your version first. claude --version should print 2.1.287 or later. The Desktop app ships its own copy of Claude Code, and mods work there from 2.1.286. Type /status in the Code tab to see which one you have.
What I tested. On October 7, 2026 I built three mods on Claude Code 2.1.293 in the Desktop app's Code tab on macOS: a command guard, a turn meter above the prompt, and a
/pyenvcommand for Python work. All three passclaude plugin validateand 8 automated tests underclaude plugin test. I also ran the guard live in a session with hot reloading on. The community sections draw on about 50 Japanese posts on Zenn, Qiita and note.com from October 1 to 7, the official mods docs (opens in a new tab), and the scan data of the awesome-claude-code-mods (opens in a new tab) catalogue from October 6.
- Claude Code Mods Tutorial: Build and Test Your First Mod, Plus 15 Community Examples
- How to Use Pi Coding Agent 1.0: Install, Connect a Model, and Run Your First Task
- How to Use Laya: Classify Text in a pandas DataFrame with the Open-Source Jev Alternative
- Claude Code Reads AGENTS.md Now: Setup, Loading Modes, and Sharing One File with Codex, OpenCode, and Cursor
- How to View Deleted Reddit Posts (2026 Guide): 5 Ways That Still Work
- GPT Image 2.5: How to Use It, Flare vs Sunburst, and API Pricing
- How to Use DeepSeek Harness: Install, Set Up, and Run Your First Agent
- Runcell Science: An Open Source Alternative to Claude Science for Research Workflows
- How to Make Mac Not Sleep: Keep Codex, Claude Code, and AI Agents Running
- OpenClaw vs ZeroClaw vs Pi Agent vs Nanobot: Which AI Agent Stack Should You Choose in 2026?
- Can Claude Code Analyze Jupyter Notebooks for Data Science? What It Actually Does
- Claude Code Routines: Why AI Agent Cron Jobs Matter
- Claude Code Desktop Bypass Permissions: How to Enable It
- How to Build Two Python Agents with Google’s A2A Protocol - Step by Step Tutorial
- Top 10 growing data visualization libraries in Python in 2025
What a mod is, and when to use something else
Before mods, you extended Claude Code from the outside, with settings hooks, skills, a status line script or an MCP server. The difference is where the code runs. A settings hook is a script Claude Code starts from the outside. A mod's functions run inside Claude Code's own process, so a mod can draw in the interface and none of the others can.
The official overview compares them roughly like this:
| Mod | Settings hook | Skill | MCP server | |
|---|---|---|---|---|
| What it is | JS or TS functions in a plugin | A shell command, HTTP call or prompt in settings.json | A SKILL.md file of instructions | An external process that gives Claude tools |
| Can draw in the UI | Yes | No | No | No |
| What it can change | Tool calls, prompts, commands, turns, the UI | Whether a call goes ahead, its arguments and result, extra context | What Claude knows and does | Which tools Claude has |
| Pick it when | You want a pane, a band, an instant command, or to rewrite an event | You already have a script that blocks or logs | You keep pasting the same instructions | Claude needs to reach an outside system |
One naming trap. On the mods pages, "hook" means a mod's function, and the old kind is called a "settings hook". Settings hooks still work and are not deprecated. One plugin can hold a mod, a skill and an MCP server together.
Where mods work
Hooks run almost everywhere. Drawing only happens in the terminal and the Desktop app.
| Where you run Claude Code | Hooks run | Mod UI appears |
|---|---|---|
claude in a terminal, editor terminals and JetBrains included | Yes | Yes |
| Desktop app, Code tab | Yes | Yes, except terminal-only elements |
| Desktop app, WSL session | No | No |
| VS Code extension chat panel | Yes | No |
claude -p and the Agent SDK | Yes | No |
| Cloud sessions | Yes, if the plugin reaches the session | No |
This table comes from the official overview. It means a guard mod still protects you in VS Code and in claude -p, while a meter has nowhere to draw.
Watch for two copies of Claude Code. Several Japanese authors got confusing validate errors because an old terminal CLI from Homebrew, versions 2.1.226 and 2.1.234 in their reports, sat next to the Desktop app's bundled 2.1.286. Check the version in each place you use.
How a mod is laid out
A small mod is three files:
safe-shell/
├── .claude-plugin/
│ └── plugin.json name, version, description
└── hooks/
├── hooks.json { "modules": ["./register.ts"] }
└── register.ts your coderegister.ts exports one function, register(on). Each on(event, matcher, hook) call adds a hook, and every hook gets the same three arguments.
$is the engine. Everything outside your own code goes through it:$.ui.toast,$.process.run,$.state,$.model.complete.eis the event, for example the Bash command Claude is about to run.next(e)hands the event to the other mods and then to Claude Code. Return without calling it and your hook answers for itself. Callnext({ ...e, command })and you change what happens.
The module has no Node and no DOM. That limit is what lets Claude Code list everything a mod calls before it runs. Try it on any mod folder:
claude plugin validate ./safe-shellMod 1: a guard that blocks destructive commands
If you run Claude in auto mode or with bypass permissions, nothing stops it before git reset --hard. This mod refuses three kinds of shell command and keeps .env files out of the conversation.
import type { Register } from 'claude-code'
// Shell commands an agent should never run without a human typing them.
const BLOCKED: { pattern: RegExp; reason: string }[] = [
{
pattern: /\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+(\/|~|\$HOME)\/?\*?(\s|$)/,
reason: 'recursive delete of / or the home folder',
},
{
pattern: /\bgit\s+push\b.*(--force\b|--force-with-lease\b|\s-f\b).*\b(main|master)\b/,
reason: 'force push to main or master',
},
{
pattern: /\bgit\s+reset\s+--hard\b/,
reason: 'git reset --hard throws away uncommitted work',
},
]
// .env, .env.local, config/.env.production ... but not .env.example.
const isSecretEnvFile = (path: string) =>
/(^|\/)\.env(\.[^/]+)?$/.test(path) && !/\.(example|sample|template)$/.test(path)
export const register: Register = on => {
on('tool.call', { tool: 'Bash' }, ($, e, next) => {
const hit = BLOCKED.find(rule => rule.pattern.test(e.command))
if (hit === undefined) {
return next(e)
}
$.ui.toast(`safe-shell blocked: ${hit.reason}`)
return { deny: `safe-shell: blocked (${hit.reason}). Ask the user to run it themselves.` }
}).catch(($, e, next) =>
// Fail closed: if the guard itself breaks, refuse instead of letting the command through.
next.called ? next(e) : { deny: 'safe-shell: the guard failed, so the command was not run.' },
)
for (const tool of ['Read', 'Edit', 'Write'] as const) {
on('tool.call', { tool }, ($, e, next) =>
isSecretEnvFile(e.file_path)
? { deny: `safe-shell: ${e.file_path} holds secrets and stays out of the conversation.` }
: next(e),
).catch(($, e, next) =>
next.called ? next(e) : { deny: 'safe-shell: the guard failed, so the file was not touched.' },
)
}
}Two details matter more than the regexes.
The guard decides before it calls next. Once next runs, the command has run. A deny returned after that undoes nothing.
The .catch makes it fail closed. A hook that throws, or runs past its 10-second budget, is skipped, and a skipped guard lets the command through. The next.called ? next(e) : deny handler refuses instead. validate reports every gating hook and whether it has a .catch, which is the first thing to check on someone else's guard:
❯ ./register.ts hooks: tool.call{tool=Bash}, tool.call{tool=Read|Edit|Write}
❯ ./register.ts gating hook with .catch: tool.call{tool=Bash}
❯ ./register.ts gating hook with .catch: tool.call{tool=Read|Edit|Write}
❯ ./register.ts calls: $.ui.toast
✔ Validation passedTest it without a session
claude plugin test runs a mod's *.test.ts files against the engine, with no session, sign-in or network. The test kit has nothing beneath your mod, so each test registers stand-in hooks for whatever the mod passes on:
import { expect, test } from 'claude-code/testing'
const ran = { stdout: 'ran', stderr: '', interrupted: false }
test('blocks rm -rf on the home folder', async ($, on) => {
on('tool.call', { tool: 'Bash' }, () => ({ result: ran }))
const answer = await $.tool.call({ tool: 'Bash', command: 'rm -rf ~' })
expect(answer.deny).toContain('recursive delete')
})
test('lets ordinary commands through', async ($, on) => {
on('tool.call', { tool: 'Bash' }, () => ({ result: ran }))
const answer = await $.tool.call({ tool: 'Bash', command: 'rm -rf ./build && python -m pytest -q' })
expect(answer.deny).toBeUndefined()
})The full file has four tests:
(pass) blocks rm -rf on the home folder [16.23ms]
(pass) blocks a force push to main [7.73ms]
(pass) lets ordinary commands through [7.22ms]
(pass) keeps .env out of Read but allows .env.example [9.40ms]
4 pass
0 failMy first run failed with on("tool.call") after the test first called $. Register the stand-ins at the top of each test, before the first $ call.
What happened in a live session
With hot reloading on, I had Claude run echo "the words git reset --hard inside an echo". The guard refused it with "blocked (git reset --hard throws away uncommitted work)", and a toast appeared. That echo was harmless. Pattern matching can't tell a command from text that mentions it, and it also misses a command hidden in a variable, a script or an alias. Japanese authors who shipped similar guards, such as rafi-guard, delete-guard and board-guard, report both problems.
Treat a guard mod as a second net. Anything that must never happen also belongs in a permission deny rule. Be aware of the other direction too. The official docs say that on a personal machine with no managed settings, a mod can approve a call that a deny rule refuses. Mods are not sandboxed. They run with your permissions.
Mod 2: a meter above the prompt
The band above the prompt and the side pane are where mods draw most. In the October 6 catalogue scan, 41% of mods draw in the band and 40% open a pane. This one shows the last turn's time, tool calls and tokens, with a Hide button.
Values that a drawing reads belong in $.state, and each one is declared in a small types file:
// types/index.d.ts, named in plugin.json as "types": "./types/index.d.ts"
export type TurnStats = {
seconds: number
tools: number
inputTokens: number
outputTokens: number
model: string | null
}
declare module 'claude-code' {
interface PluginState {
'turn-meter': { last: TurnStats | null; isHidden: boolean }
}
}The hooks count tool calls, read durationMs and usage from turn.complete, and draw the band:
import { atom, read, update } from 'claude-code'
import type { Register } from 'claude-code'
import type { TurnStats } from '../types'
const last = atom({ plugin: 'turn-meter', key: 'last' } as const, null)
const isHidden = atom({ plugin: 'turn-meter', key: 'isHidden' } as const, false)
const formatTokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
export const register: Register = on => {
let tools = 0
on('prompt.submit', ($, e, next) => {
tools = 0
return next(e)
})
on('tool.call', ($, e, next) => {
// Count the main conversation's calls; subagents carry an agentId.
if (e.agentId === undefined) tools += 1
return next(e)
})
on('turn.complete', async ($, e, next) => {
if (e.agentId !== undefined) return next(e)
const usage = e.usage
const stats: TurnStats = {
seconds: Math.round(e.durationMs / 1000),
tools,
inputTokens: usage
? usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
: 0,
outputTokens: usage?.output_tokens ?? 0,
model: usage?.model ?? null,
}
await update($, last, () => stats)
if (stats.seconds > 120) $.ui.toast(`That turn took ${stats.seconds}s`)
return next(e)
})
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const stats = await read($, last)
if (e.props.hasSurvey || stats === null || (await read($, isHidden))) return next(e)
const { Box, Button, Text } = $.ui.resolve(e)
const model = stats.model === null ? '' : ` · ${stats.model}`
// The band is shared by every mod: keep what the mods beneath drew, then add a row.
const below = await next(e)
return (
<Box flexDirection="column">
{below}
<Box>
<Text dimColor>
{`Last turn: ${stats.seconds}s · ${stats.tools} tool calls · ${formatTokens(stats.inputTokens)} in / ${formatTokens(stats.outputTokens)} out${model} `}
</Text>
<Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
</Box>
</Box>
)
})
}In the test, on both the terminal and Desktop surfaces, the band reads:
Last turn: 42s · 0 tool calls · 18.2k in / 800 out · claude-opus-5-5 [ Hide ]Three things I learned building it:
- Keep drawn values in
$.state, not in module variables. A hot reload runsregisteragain and resets your variables, while$.statesurvives./clearstarts over, so write to$.storeonsession.endif a value must outlive it. - The band is shared. Call
next(e)and put its result inside yourBox, or you hide every other mod's row. A note.com author lost one band to another mod before working this out. Textelements keep nokey. In tests, find them by what they say:ui.find({ type: 'Text', text: /Last turn/ }).
Mod 3: /pyenv, for when Claude runs the wrong Python
I went looking for data-science mods and found a gap. Of the 2,690 mods in the October 6 scan, none mentions Jupyter, pandas, DataFrames, Polars, venv or conda in its name or description.
The problem behind this mod is common in data work. Claude's Bash tool runs whatever python3 comes first on your PATH, and that is often not your project's environment. Here is what the probe printed on my machine:
python: /Users/<you>/.local/bin/python3 (3.14.2)
env: none (system or tool-managed Python)
pandas: 3.0.1
numpy: 2.4.2
polars: not installed
matplotlib: 3.11.0
pygwalker: not installedNo virtual environment was active, so a pip install from Claude would have gone into a global interpreter.
The /pyenv command runs that probe and answers in two parts. text is the row you see. context is a note that only the model reads, so Claude's next Bash calls know which interpreter is in use without another prompt from you.
import type { Register } from 'claude-code'
// Reads package versions from metadata instead of importing them, so it stays fast.
const PROBE = `
import os, sys
from importlib.metadata import PackageNotFoundError, version
env = os.environ.get("VIRTUAL_ENV") or os.environ.get("CONDA_PREFIX") or "none (system or tool-managed Python)"
print(f"python: {sys.executable} ({sys.version.split()[0]})")
print(f"env: {env}")
for name in ("pandas", "numpy", "polars", "matplotlib", "pygwalker"):
try:
print(f"{name}: {version(name)}")
except PackageNotFoundError:
print(f"{name}: not installed")
`
export const register: Register = on => {
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'pyenv',
description: "Show which Python, environment and data packages this session's shell uses",
})
return next(e)
})
on('command.run', { command: 'pyenv' }, async $ => {
try {
const probe = await $.process.run(['python3', '-c', PROBE], { timeoutMs: 15_000 })
if (probe.exitCode !== 0) {
return { text: `pyenv: python3 exited with ${probe.exitCode}: ${probe.stderr.trim().slice(0, 300)}` }
}
const report = probe.stdout.trim()
// `text` is the row you see; `context` is a note only the model reads.
return {
text: report,
context: [`Python environment of this session's shell, from /pyenv:\n${report}`],
}
} catch (error) {
return { text: `pyenv: could not run python3 (${String(error).slice(0, 200)})` }
}
})
}A mod's slash command runs your function at once, with no model turn, even while Claude is busy. $.process.run takes an argument list with no shell, and it times out after 30 seconds unless you set timeoutMs.
Does $.process.run see the same PATH as Claude's Bash tool? A community author found nvm's node missing from a mod's processes, so I checked with a temporary probe mod. In my Desktop session, a plain $.process.run got exactly the Bash tool's PATH and the same python3. Running the probe through a login shell, zsh -lc, gave the wrong answer. It resolved python.org's 3.10.9, because a non-interactive login shell skips ~/.zshrc, where most PATH additions live. Keep the plain call. If a reading looks off on your machine, compare it with !which python3 typed at the Claude Code prompt.
Testing it showed one more trap. Calls on $, such as $.process.run and $.command.register, need stand-ins too, and a stand-in for a $ call answers { value: ... }. Return the bare result and the kit refuses it.
If most of your analysis happens in notebooks rather than scripts, this only goes so far, because Claude Code edits .ipynb files as JSON and never sees the live kernel. RunCell (opens in a new tab) is a notebook agent that runs cells and reads their output directly, so it suits that kind of work better.
Let Claude write the mod
None of this has to be written by hand. Ask in a session, for example "make a mod that shows my 5-hour usage above the prompt". Claude loads the built-in plugin-authoring skill and writes the files to ~/.claude/dev-mods/<session-id>/. The first file triggers one question, "Enable hot reloading for this session?". Once you agree, each edit reloads when Claude's turn ends. That is how the three mods above were built and loaded.
What the docs and the community add:
- Copy the folder somewhere permanent. Authors on Qiita and Zenn report that dev-mods folders get cleaned up with old sessions after about 30 days.
- Develop with
claude --plugin-dir ./my-mod. An installed mod runs a cached copy, so editing its folder changes nothing until you update the plugin. - The Desktop app takes no flags. Set
CLAUDE_CODE_PLUGIN_DIRSin theenvblock of~/.claude/settings.json. Project settings are ignored for this. - Run
/plugin. A dim line such as1 mod active · safe-shellconfirms the mod loaded. - Start with
claude --debugwhile you build. The debug log names every skipped hook and every drawing the engine refused.
15 community mods worth studying
The awesome-claude-code-mods (opens in a new tab) catalogue, at 316 stars, scanned 2,690 public mods in 1,265 repositories on October 6, five days after launch. Its scan data shows what people build:
- 68% add a slash command
- 53% hook tool calls
- 41% draw above the prompt, 40% open a pane
- 40% start processes, 12% call a model, 11% use the network
These 15 show the range. Stars are as of October 7, 2026.
| Mod | What it does | Stars | Worth reading for |
|---|---|---|---|
| terminal-browser (opens in a new tab) | A web browser beside the conversation for sites, local HTML previews and pull requests | 3,695 | The most ambitious pane so far |
| claude-image-view (opens in a new tab) | Thumbnails of pasted images above the prompt instead of [Image #1] | 160 | Drawing images in the terminal |
| hamzafer/claude-code-mods (opens in a new tab) | A set: context bar, blast-radius guard, Markdown preview, subagent dashboard | 138 | Several mod types in one repo |
| claude-auto-handoff (opens in a new tab) | Hands a long session to a fresh one with a structured brief | 60 | Context management |
| prismantis (opens in a new tab) | Themed replies: tables, code, charts as box art, right-to-left text | 49 | Restyling Claude's own messages |
| cc-arcade (opens in a new tab) | Nine games above the prompt that pause when Claude finishes | 41 | Animation with a frame clock |
| jev-permission-gate (opens in a new tab) | A Jev decision model judges tool calls in auto mode | 28 | A careful privacy section: it sends your last three messages, up to 1,500 characters each, to TypeSafe's API |
| prompt-rail (opens in a new tab) | A rail of your past prompts; hover to read, click to jump | 22 | The centerpiece of the most-liked Japanese mods review on Zenn |
| claude-paste-view (opens in a new tab) | Previews pasted images and long pasted text | 18 | Small, readable code |
| claude-gfm-render (opens in a new tab) | GitHub alerts, task lists and Mermaid in replies, as box art or SVG | 14 | Drawing differently per surface |
| claude_qamods (opens in a new tab) | qa-guide explains Claude's questions and options in a side pane | 10 | Improving a built-in dialog |
| CC-Usage-Band (opens in a new tab) | 5-hour and 7-day limits, context and cache hit rate above the prompt | 10 | The most common kind of mod: a meter |
| harness-scope (opens in a new tab) | Per-repo profiles that hide global skills, agents and rules | 1 | The author measured the skill listing shrinking from 98 entries to 49 |
| touch-map (opens in a new tab) | Shows which files Claude listed, read, edited or created | 0 | Its author found Claude touched 125 of 304 files during one refactor survey |
| claude-mods-router (opens in a new tab) | Routes each prompt to an effort level with $.model.classify | 0 | Its Qiita write-up (26 likes) notes that switching models breaks prompt caching |
Anthropic also publishes three samples in claude-code-playground (opens in a new tab): token-weather, blast-radius and replay-theater. The source of the built-in mods, /diff among them, is in the claude-code repository (opens in a new tab).
Best practices the community settled on
In the first week, Japanese authors alone published about 50 posts with working mods, measurements and failure reports. These are the habits that came up again and again.
- Read a mod before you install it. Clone it, run
claude plugin validate, and read thehooks:andcalls:lines. Read the source wherever$.process,$.http,$.fs.write,$.env.getor$.modelappears. Validate again after every update. A pass means the engine can load the code, not that the code is safe. - Guards decide before
nextand fail closed. Mods that only watch should fail open with.catch(($, e, next) => next(e)), so a bug in a meter never blocks your work. - Wait inside
$calls. A hook gets 10 seconds of its own time. A$.process.runor$.ui.askin flight doesn't count against it, but your own promises do. - Never store or destructure
$. Pass it only to functions declared at the top level of the file. Several authors had mods that silently failed to load until they fixed this. - Mind the prompt cache. Switching models mid-session, or changing the system prompt between requests, throws the cache away.
- Branch on
e.surface.Svgdraws only in the Desktop app.RasterandImagedraw only in the terminal. - Pin a version for team use. The API is in early access and changes between releases. Five of the six releases after launch, 2.1.288 to 2.1.293, changed or fixed mod behavior.
Masking secrets is the cautionary tale. One note.com author built a mod that replaced API keys in tool output before Claude saw them. Claude still printed the raw values while debugging a failed edit with od -c, and a full-file Write saved the placeholders over the real keys. Only a permission deny rule on the file held. A mod that changes what the screen shows hides nothing from the model.
Common problems
| Symptom | Likely cause | Fix |
|---|---|---|
/plugin doesn't list the mod | Untrusted folder, --safe-mode, --bare or disableAllHooks | Trust the folder and restart without the flag |
hooks modules are turned off in this process | Anthropic turned installed mods off remotely | Nothing to change locally. Update, since 2.1.289 and 2.1.290 fixed two cases where mods stayed off |
validate fails with "expected record, received undefined" | An old CLI earlier on your PATH | Run claude --version and update |
| A hook is skipped with no visible error | It threw, for example by calling $.plugin.name(), which is a property | Run claude --debug and read the skip line |
| A pane never opens | A pane that opens without you asking waits for 144 columns, docks beside the transcript only in the fullscreen layout, and /diff can cover it | Widen the window, try the fullscreen layout, close /diff |
| It works in the terminal but not in Desktop | Desktop bundles its own version, and some elements are terminal-only | Check /status in the Code tab and branch on e.surface |
No mod UI in VS Code or claude -p | Those surfaces don't draw | Fall back to a transcript line or a command's text |
| A test fails with "nothing beneath the plugins answers" | The test kit has no engine | Add stand-in hooks, and answer $ calls with { value } |
Share your mod
Put a marketplace.json next to plugin.json inside .claude-plugin/:
{
"name": "my-mods",
"owner": { "name": "you" },
"plugins": [{ "name": "safe-shell", "source": "./" }]
}Push the folder to GitHub. Anyone can then install it from a terminal session:
/plugin install safe-shell --marketplace you/safe-shellIn the Desktop app the route is + then Plugins then Add plugin, in local and SSH sessions. If you add the claude-code-mod topic to the repository, the community catalogue usually picks it up within a few hours of your next push.
FAQ
Related Guides
- Claude Code Bypass Permissions: Desktop Toggle, CLI Flags, and Error Fixes
- Claude Code Reads AGENTS.md: Setup, Loading Modes, and Sharing One File
- Claude Code Routines
- Build Claude Code with the Claude Agent SDK
- How to Use Pi Coding Agent 1.0