Skip to content

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

Updated on

Claude Code mods (v2.1.287+) are plugins of TypeScript functions that draw panes, add instant slash commands and guard tool calls. Build and test three working mods, browse 15 community mods, and run the safety checks before you install one.

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 toDo this
Get a mod without writing codeDescribe 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 buildclaude --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 /pyenv command for Python work. All three pass claude plugin validate and 8 automated tests under claude 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.

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:

ModSettings hookSkillMCP server
What it isJS or TS functions in a pluginA shell command, HTTP call or prompt in settings.jsonA SKILL.md file of instructionsAn external process that gives Claude tools
Can draw in the UIYesNoNoNo
What it can changeTool calls, prompts, commands, turns, the UIWhether a call goes ahead, its arguments and result, extra contextWhat Claude knows and doesWhich tools Claude has
Pick it whenYou want a pane, a band, an instant command, or to rewrite an eventYou already have a script that blocks or logsYou keep pasting the same instructionsClaude 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 CodeHooks runMod UI appears
claude in a terminal, editor terminals and JetBrains includedYesYes
Desktop app, Code tabYesYes, except terminal-only elements
Desktop app, WSL sessionNoNo
VS Code extension chat panelYesNo
claude -p and the Agent SDKYesNo
Cloud sessionsYes, if the plugin reaches the sessionNo

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 code

register.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.
  • e is 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. Call next({ ...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-shell

Mod 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 passed

Test 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 fail

My 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 runs register again and resets your variables, while $.state survives. /clear starts over, so write to $.store on session.end if a value must outlive it.
  • The band is shared. Call next(e) and put its result inside your Box, or you hide every other mod's row. A note.com author lost one band to another mod before working this out.
  • Text elements keep no key. 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 installed

No 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_DIRS in the env block of ~/.claude/settings.json. Project settings are ignored for this.
  • Run /plugin. A dim line such as 1 mod active · safe-shell confirms the mod loaded.
  • Start with claude --debug while 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.

ModWhat it doesStarsWorth reading for
terminal-browser (opens in a new tab)A web browser beside the conversation for sites, local HTML previews and pull requests3,695The most ambitious pane so far
claude-image-view (opens in a new tab)Thumbnails of pasted images above the prompt instead of [Image #1]160Drawing images in the terminal
hamzafer/claude-code-mods (opens in a new tab)A set: context bar, blast-radius guard, Markdown preview, subagent dashboard138Several mod types in one repo
claude-auto-handoff (opens in a new tab)Hands a long session to a fresh one with a structured brief60Context management
prismantis (opens in a new tab)Themed replies: tables, code, charts as box art, right-to-left text49Restyling Claude's own messages
cc-arcade (opens in a new tab)Nine games above the prompt that pause when Claude finishes41Animation with a frame clock
jev-permission-gate (opens in a new tab)A Jev decision model judges tool calls in auto mode28A 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 jump22The centerpiece of the most-liked Japanese mods review on Zenn
claude-paste-view (opens in a new tab)Previews pasted images and long pasted text18Small, readable code
claude-gfm-render (opens in a new tab)GitHub alerts, task lists and Mermaid in replies, as box art or SVG14Drawing differently per surface
claude_qamods (opens in a new tab)qa-guide explains Claude's questions and options in a side pane10Improving 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 prompt10The most common kind of mod: a meter
harness-scope (opens in a new tab)Per-repo profiles that hide global skills, agents and rules1The 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 created0Its 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.classify0Its 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.

  1. Read a mod before you install it. Clone it, run claude plugin validate, and read the hooks: and calls: lines. Read the source wherever $.process, $.http, $.fs.write, $.env.get or $.model appears. Validate again after every update. A pass means the engine can load the code, not that the code is safe.
  2. Guards decide before next and 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.
  3. Wait inside $ calls. A hook gets 10 seconds of its own time. A $.process.run or $.ui.ask in flight doesn't count against it, but your own promises do.
  4. 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.
  5. Mind the prompt cache. Switching models mid-session, or changing the system prompt between requests, throws the cache away.
  6. Branch on e.surface. Svg draws only in the Desktop app. Raster and Image draw only in the terminal.
  7. 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

SymptomLikely causeFix
/plugin doesn't list the modUntrusted folder, --safe-mode, --bare or disableAllHooksTrust the folder and restart without the flag
hooks modules are turned off in this processAnthropic turned installed mods off remotelyNothing 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 PATHRun claude --version and update
A hook is skipped with no visible errorIt threw, for example by calling $.plugin.name(), which is a propertyRun claude --debug and read the skip line
A pane never opensA pane that opens without you asking waits for 144 columns, docks beside the transcript only in the fullscreen layout, and /diff can cover itWiden the window, try the fullscreen layout, close /diff
It works in the terminal but not in DesktopDesktop bundles its own version, and some elements are terminal-onlyCheck /status in the Code tab and branch on e.surface
No mod UI in VS Code or claude -pThose surfaces don't drawFall back to a transcript line or a command's text
A test fails with "nothing beneath the plugins answers"The test kit has no engineAdd 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-shell

In 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