tooling

Claude Code Mods: 10 Tested Use Cases (2026)

October 9, 2026

Claude Code Mods: 10 Tested Use Cases (2026)

Claude Code mods are plugins whose JavaScript or TypeScript functions run inside Claude Code when something happens: a tool call, a prompt, a turn, a redraw. Below are 10 working mods for common jobs, from guarding risky commands to drawing panes, and every one passes claude plugin test.

TL;DR

Anthropic added mods to Claude Code on October 1, 2026, in version 2.1.287.12 A mod hooks an event and can watch it, rewrite it, or answer it in Claude Code's place, and it can draw panes, bands and status lines of its own.3

We wrote 10 mods for everyday jobs and checked them on Claude Code 2.1.295. All 10 pass claude plugin validate, all 25 unit tests pass, and six of them also behaved as expected in live claude -p runs. The code below is copied from the tested files.

Mods run with your permissions and are not sandboxed, so read a mod before you install it.3

What You'll Learn

  • What a Claude Code mod is, and when to pick one over a settings hook, a skill or an MCP server
  • The three files every mod needs, and how to load, validate and test one
  • 10 tested use cases with full code: safety guards, prompt context, commands, panes, a custom tool and a background watcher
  • What changed in the first week after launch, and the limits that trip people up
  • How teams and organizations control which mods run

What is a Claude Code mod?

A Claude Code mod is a plugin whose code registers event handlers, called hooks.

Anthropic's docs put it this way: "A mod is a plugin that changes how Claude Code looks and behaves. It's made of JavaScript or TypeScript event handlers."3 The announcement describes them as "small TypeScript functions" that can rewrite a prompt, add new UI, replace a built-in feature, or add new functionality.1

Every hook gets the same three arguments:4

  • $, the mods API: every method a mod can call, grouped in namespaces such as $.ui, $.fs and $.process
  • e, the event: plain, frozen data such as a tool call's name and arguments
  • next, the next handler: it passes the event to the other mods and then to Claude Code's own behavior

What a hook does with next decides what kind of hook it is. Return next(e) and it only observes. Pass next a changed copy and it rewrites the event. Return a result without calling next and it answers the event itself, so Claude Code's own behavior never runs.5

Mods vs settings hooks, skills and MCP servers

Claude Code already had settings hooks, skills and MCP servers, and the docs compare all four. The short version: a mod is the only one that runs inside Claude Code's process and the only one that can draw in the interface.3

ModSettings hookSkillMCP server
What it isFunctions in a plugin that Claude Code calls in its own processA shell command, HTTP request, or prompt that Claude Code runs on a lifecycle eventA SKILL.md file of instructions Claude readsAn external process or service that gives Claude tools
What it can changeTool calls, prompts, commands, turns, and what the interface drawsWhether a tool call or prompt goes ahead, a tool call's arguments and result, and context added for ClaudeWhat Claude knows and doesWhich tools Claude has
Can it draw in the interfaceYesNoNoNo
What you writeJavaScript or TypeScriptA script plus a settings.json entryMarkdownA server in any language
Pick it whenYou want a pane, a band above the prompt, a custom command, or to rewrite an eventYou want to block, allow, or log an event with a script you already haveYou keep pasting the same instructions into chatClaude needs to reach an external system

Adapted from the Claude Code docs, "Mods overview".3 A plugin can hold all four, so a mod can ship next to a skill and an MCP server.

How do you build and test a Claude Code mod?

You need Claude Code 2.1.287 or later in the terminal. The Desktop app's Code tab runs mods from 2.1.286.3 You don't need Node.js or a build step, because Claude Code loads .js and .ts files directly.4

A mod is three files:46

my-mod/
├── .claude-plugin/
│   └── plugin.json      # the plugin manifest: name, version, description
└── hooks/
    ├── hooks.json       # {"modules": ["./register.js"]} points at your code
    └── register.js      # your hooks module, which exports register(on)

Every mod in this post uses the same two JSON files, with only the name and description changed. Here they are for the first one:

{
  "name": "confirm-risky",
  "version": "0.1.0",
  "description": "Asks before Claude runs a destructive shell command",
  "author": { "name": "NerdLevelTech" }
}
{
  "modules": ["./register.js"]
}

Three commands cover the whole loop:476

# Load the mod for one session; Claude Code hot-reloads it when you save
claude --plugin-dir ./confirm-risky

# See what Claude Code reads from the mod: the events it hooks and the API calls it makes
claude plugin validate ./confirm-risky

# Run the mod's *.test.ts files with no session, sign-in or network
cd confirm-risky && claude plugin test

You can also describe the mod you want in a Claude Code session and let Claude write it. Claude uses a built-in skill named plugin-authoring for that, and the mod only runs once you approve it.4

How we tested these 10 mods

We wrote each mod by hand from the official docs and the type declarations Claude Code ships for its version, then ran three checks on October 9, 2026 with Claude Code 2.1.295:

  1. claude plugin validate on every mod. All 10 passed.
  2. claude plugin test with 2 to 4 tests per mod, 25 in total. All 25 passed.
  3. Live claude -p runs with the Haiku model in a throwaway git repository, for seven mods. Six behaved as expected. The seventh, the turn receipt, passes its tests, but claude -p prints only the answer, so its line doesn't appear there and we checked it with tests only.

Mods that draw only appear in an interactive terminal or the Desktop app.3 We drew the band and the pane with the test kit's $.ui.mount, which renders a site without a session, and checked the status line by stubbing $.ui.status.7

A test is a TypeScript file that raises events and stubs whatever Claude Code would answer. This is the one for the push guard in use case 2. It answers $.process.run with a fake branch name, so no git runs:7

import { expect, test } from 'claude-code/testing'

// Stubs what the permission rules decided and what git says the current branch is,
// and counts how often the mod ran git
function stubs(on, branch: string, rules = 'allow') {
  const runs = { git: 0 }
  on('tool.check', () => ({ decision: rules }))
  on('process.run', () => {
    runs.git += 1
    return { value: { exitCode: 0, stdout: branch + '\n', stderr: '' } }
  })
  return runs
}

test('git push while main is checked out is denied', async ($, on) => {
  stubs(on, 'main')
  const out = await $.tool.check({ tool: 'Bash', input: { command: 'git push' } })
  expect(out.decision).toBe('deny')
})

test('a push that targets main from another branch is denied', async ($, on) => {
  stubs(on, 'feature/login')
  for (const command of ['git push origin main', 'git push origin HEAD:main', 'git push origin +main', 'git push origin feature/login:refs/heads/main', 'git push --force origin main;', 'git push origin "main"']) {
    const out = await $.tool.check({ tool: 'Bash', input: { command } })
    expect(out.decision).toBe('deny')
  }
})

test('a feature branch push keeps the rules decision', async ($, on) => {
  stubs(on, 'feature/login')
  const out = await $.tool.check({ tool: 'Bash', input: { command: 'git push origin feature/login' } })
  expect(out.decision).toBe('allow')
})

test('other commands never run git', async ($, on) => {
  const runs = stubs(on, 'main', 'ask')
  const out = await $.tool.check({ tool: 'Bash', input: { command: 'npm test' } })
  expect(out.decision).toBe('ask')
  expect(runs.git).toBe(0)
})

Terminal output from Claude Code 2.1.295 showing claude plugin validate passing for the push-guard mod, its four tests passing, and pass and fail counts for the other nine mods. Timings removed and paths shortened.

Table of the 10 Claude Code mods built for this guide, with the events each one uses, what its hook does, where you see it, how many tests it has, and whether it was also checked in a live claude -p run.

1. Ask before Claude runs a destructive shell command

Use this if you let Claude run shell commands but want a human check on the ones that can't be undone.

A tool.call hook can hold a tool call while it asks you a question. $.ui.ask shows the question in the dialog Claude uses for questions and resolves to the label you pick. It rejects when you dismiss it, and in a claude -p run where nobody can answer.5 So the hook starts from "Refuse" and only runs the command on an explicit "Run it".

// Commands worth a second look: recursive deletes (rm -r, -R, -rf, -f -r, rm dir -rf, --recursive),
// hard resets, and force pushes (--force, -f, or a +branch refspec)
const RISKY = [
  /\brm\b.*\s-[a-zA-Z]*[rR][a-zA-Z]*(?=\s|$)/,
  /\brm\b.*\s--recursive\b/,
  /\bgit\s+reset\s+--hard\b/,
  /\bgit\s+push\b.*(?:\s--force\b|\s-f\b|\s\+\S)/,
]

export function register(on) {
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    // Everything else runs as usual
    if (!RISKY.some((pattern) => pattern.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 you pick an option; Refuse is listed first
      answer = await $.ui.ask('Run this command? ' + e.command, ['Refuse', 'Run it'])
    } catch {
      // Dismissed, or a claude -p run with nobody to ask
    }
    if (answer !== 'Run it') {
      return { deny: 'The user declined this command. Ask before trying another approach.' }
    }
    return next(e)
  }).catch(async ($, e, next) => {
    // If the guard itself fails before the command ran, refuse instead of failing open
    return next.called ? next(e) : { deny: 'The confirm-risky guard failed, so this command was not run.' }
  })
}

The .catch matters. A hook that throws or times out before it calls next is skipped, and the command would run. The .catch handler answers in its place, so this guard fails closed.5

Three tests cover six safe commands that must not trigger a question (among them rm notes.txt, docker rm -f web and npm run build -- --force-color), eight risky ones that must be refused (rm -rf, rm -Rf, rm -R, rm -f -r, rm build -rf, rm --recursive, git reset --hard and a +main force push), and an approved git push -f.

Live, we asked Claude to run rm build -rf with the mod loaded. Claude replied "The command was declined, so nothing ran and build is untouched." The build folder was still there.

One caveat: the patterns are text matching. They miss destructive commands that don't look like these, such as find . -delete or a script that deletes files, so treat the mod as a speed bump, not a sandbox. It can also ask about harmless commands, such as grep -r run after a git rm. Refuse is listed first on purpose, so a careless Enter doesn't run the command.

2. Block pushes to main, whatever the rules say

This is for teams that want Claude to push feature branches but never main.

tool.check fires after your permission rules and settings hooks have decided whether a call may run, and next(e) resolves to that decision: allow, ask, or deny. A hook can return a different decision.5

That makes it the right event for a check that depends on what's true right now, such as the current branch. This mod refuses a push that names main as its target (origin main, HEAD:main, +main, refs/heads/main or a quoted "main"), and any push while main is checked out.

export function register(on) {
  // tool.check fires after your permission rules and settings hooks have decided
  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
    // What the rules decided: allow, ask, or deny
    const decided = await next(e)
    const command = e.input.command
    if (!/\bgit\s+push\b/.test(command)) return decided
    // A push that names main as its target: "origin main", "HEAD:main", "+main", "refs/heads/main", "'main'"
    const namesMain = /\bgit\s+push\b.*(?:[\s:+'"]|refs\/heads\/)main(?=$|[\s;&|'"])/.test(command)
    // Or any push while main is checked out
    const branch = await $.process.run(['git', 'branch', '--show-current'])
    const onMain = branch.exitCode === 0 && branch.stdout.trim() === 'main'
    if (!namesMain && !onMain) return decided
    return { decision: 'deny', reason: 'Pushing to main is blocked here. Push a feature branch and open a pull request instead.' }
  }).catch(async ($, e, next) => {
    // If the check fails, for example git can't run, refuse the push rather than let it through
    return { decision: 'deny', reason: 'push-guard could not check this push (' + next.error.kind + '), so it was refused.' }
  })
}

Four tests stub the branch name and the rules' decision, and one counts git runs to prove other commands never start it.

Live, with Bash(git push:*) allowed, git push origin main on main came back with "Permission to use Bash denied by plugin push-guard: Pushing to main is blocked here. Push a feature branch and open a pull request instead."

From a branch named feature/demo, git push origin HEAD:main and git push origin +main got the same refusal, and the remote only ever had a feature/demo branch.

A push of feature/demo itself was allowed and reported "Everything up-to-date".

The .catch refuses the push if the check itself fails, for example when git can't run. Still, the docs say plainly that a hook matching command text is a reminder for Claude. A spelling the pattern doesn't expect, such as git -C . push, gets through. A harmless command can be refused too, such as git push origin feature/login && git log main. To stop pushes to main for everyone, protect the branch on your Git host.5

3. Mask secrets in command output before Claude reads them

Reach for this when shell output can contain credentials, such as cloud config files or environment dumps.

This hook lets the command run, then rewrites what came back. The announcement lists redacting secrets from tool output as one of the things a mod can do with a single function.1 Returning a new result object, rather than the one next gave you, is what makes Claude read your version.

A failed command needs its own branch. Its output comes back with isError set and the text Claude reads in text, not as stdout.8 The mod masks that text and returns it as a deny, so Claude reads only the masked text.

// Common credentials: AWS key ids (AKIA, ASIA), AWS secret keys and session tokens,
// GitHub tokens, Slack tokens, and private key blocks
const SECRETS = [
  [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, '[REDACTED]'],
  [/\b(aws_secret_access_key|aws_session_token)(\s*[=:]\s*)\S+/gi, '$1$2[REDACTED]'],
  [/\bgh[pousr]_[A-Za-z0-9]{36,}\b/g, '[REDACTED]'],
  [/\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, '[REDACTED]'],
  [/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g, '[REDACTED]'],
  [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED]'],
]

function redact(text) {
  return SECRETS.reduce((out, [pattern, replacement]) => out.replace(pattern, replacement), text)
}

export function register(on) {
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    // Let the command run, then look at what it printed
    const ran = await next(e)
    // A refused call has nothing to clean
    if (ran.deny !== undefined) return ran
    if (ran.isError) {
      // A failed command's output reaches Claude as error text
      const text = typeof ran.text === 'string' ? ran.text : typeof ran.result === 'string' ? ran.result : ''
      const masked = redact(text)
      if (masked === text) return ran
      $.ui.log('masked a credential in the error output of: ' + e.command.slice(0, 60))
      // An error result can't be rewritten in place, so withhold it and pass on the masked text
      return { deny: masked }
    }
    // Another mod may have answered with a plain string; leave that alone
    if (typeof ran.result !== 'object' || ran.result === null) return ran
    const stdout = redact(ran.result.stdout ?? '')
    const stderr = redact(ran.result.stderr ?? '')
    if (stdout === ran.result.stdout && stderr === ran.result.stderr) return ran
    $.ui.log('masked a credential in the output of: ' + e.command.slice(0, 60))
    // Return a new result, so Claude reads the masked text and not the original
    return { result: { ...ran.result, stdout, stderr } }
  }).catch(async () => {
    // If masking itself fails, withhold the output rather than pass it on unmasked
    return { deny: 'mask-secrets could not check this output, so it was withheld.' }
  })
}

Four tests check that an AWS key id and secret key are masked, that clean output and clean errors pass untouched, that a key in a failed command is masked, and that the .catch withholds output if masking fails.

Live, we put a fake AWS credentials file in creds.txt and asked Claude to quote cat creds.txt verbatim. With the mod, Claude quoted aws_access_key_id = [REDACTED] and aws_secret_access_key = [REDACTED]. Without it, an earlier run of the same prompt quoted the full fake key id. A failing command, cat creds.txt; exit 3, came back masked too.

It covers Bash output and only the patterns listed. Claude can still open the same file with the Read tool, so pair it with a guard on reads. Our earlier post shows one: a Claude Code mod that blocks .env reads.

4. Add the current git branch to prompts about pull requests

This saves you from typing repository facts into every prompt.

A prompt.submit hook sees each prompt before the turn starts. Passing next an event with an extra context entry adds text that Claude reads after your prompt, while the transcript still shows what you typed.5

export function register(on) {
  on('prompt.submit', async ($, e, next) => {
    // Leave prompts that don't mention a pull request alone
    if (!/\bPR\b|pull request/i.test(e.text)) return next(e)
    let git
    try {
      git = await $.process.run(['git', 'branch', '--show-current'])
    } catch {
      // git isn't installed, so send the prompt as it is
      return next(e)
    }
    // Outside a git repository there is no branch to add
    if (git.exitCode !== 0) return next(e)
    // Claude reads this line after your prompt; the transcript still shows what you typed
    return next({ ...e, context: [...(e.context ?? []), 'Current branch: ' + git.stdout.trim()] })
  })
}

Two tests check that a prompt mentioning a PR gets Current branch: feature/auth, and count git runs to prove other prompts never start it. Live, with Bash disabled so Claude couldn't look it up, a prompt mentioning a PR got the answer feature/demo. A question without a PR mention got "Unknown", because no branch was added.

Claude reads the added line with every prompt that matches, so keep it short and add it only when it's useful, as this mod does.

5. Add an instant slash command that skips the model

Some lookups don't need Claude to think at all.

A command registered by a mod runs your function at once, with no Claude turn.3 Register it in session.start, answer it in command.run, and add immediate: true so it also works while Claude is busy.9

export function register(on) {
  on('session.start', async ($, e, next) => {
    // immediate: true lets /todos run even while Claude is working
    await $.command.register({ name: 'todos', description: 'List TODO and FIXME lines in tracked files', immediate: true })
    return next(e)
  })

  on('command.run', { command: 'todos' }, async ($) => {
    let out
    try {
      // git grep searches tracked files only and needs no shell
      out = await $.process.run(['git', 'grep', '-n', '-I', '-E', 'TODO|FIXME'])
    } catch {
      return { text: 'Could not run git grep.' }
    }
    // git grep exits 1 when nothing matches
    if (out.exitCode === 1) return { text: 'No TODO or FIXME lines found.' }
    if (out.exitCode !== 0) return { text: 'git grep failed: ' + out.stderr.trim() }
    const lines = out.stdout.trim().split('\n')
    const shown = lines.slice(0, 20).join('\n')
    const more = lines.length > 20 ? '\n... and ' + (lines.length - 20) + ' more' : ''
    return { text: lines.length + ' TODO/FIXME lines:\n' + shown + more }
  })
}

Two tests cover a match and no match. Live, in a repository with two marked lines, claude -p "/todos" --plugin-dir ./todo-scan printed:

todo-scan: 2 TODO/FIXME lines:
app.js:2:// TODO handle empty input
app.js:3:// FIXME flaky retry

No model call was needed. Claude Code puts the plugin's name in front of a command's text.4

Watch the name you pick: $.command.register throws if a built-in command already uses the name, and a hook that throws is skipped, so register commands last in session.start or wrap them in try.9

6. Show a receipt under each answer: time taken and tokens used

Handy if you watch how long turns take or how many tokens they cost.

turn.complete fires when a turn ends. The event carries durationMs, the turn's token usage, and isAborted, and a hook can return a text field that shows as a line under the answer.5

export function register(on) {
  on('turn.complete', async ($, e, next) => {
    const result = await next(e)
    // Subagent turns and interrupted turns get no receipt
    if (e.agentId || e.isAborted) return result
    const seconds = (e.durationMs / 1000).toFixed(1)
    const tokens = e.usage ? ' · ' + e.usage.output_tokens + ' output tokens' : ''
    // A text other than the answer itself is shown under the answer
    return { ...result, text: 'Done in ' + seconds + 's' + tokens }
  })
}

Two tests check the line for a 12,345 ms turn with 420 output tokens (Done in 12.3s · 420 output tokens) and that an interrupted turn gets none. In a claude -p run only the answer is printed, so this line shows in an interactive session.

The hook skips subagent turns, which also fire turn.complete with agentId set.5

7. Draw a context meter above the prompt

It's useful when you want to see the context window filling up before it's full.

The band above the prompt is a render site every mod shares. A ui.render hook filtered to { component: 'AbovePrompt' } returns what to draw there, or next(e) to draw nothing.10 $.session.usage() returns the context window's tokens, window and percent, among other figures.6

// A 20-cell bar, filled in proportion to the percent used
function bar(percent) {
  const filled = Math.round(percent / 5)
  return '█'.repeat(filled) + '░'.repeat(20 - filled)
}

export function register(on) {
  // After each turn the context has grown, so ask for a redraw of the band
  on('turn.complete', async ($, e, next) => {
    const result = await next(e)
    $.ui.invalidate('ui.render')
    return result
  })

  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
    // Give way to a survey that wants the band
    if (e.props.hasSurvey) return next(e)
    const { context } = await $.session.usage()
    // No reading yet, before the first response
    if (context.percent === undefined) return next(e)
    const { Text } = $.ui.resolve(e)
    const color = context.percent >= 80 ? 'red' : context.percent >= 60 ? 'yellow' : 'green'
    const used = Math.round((context.tokens ?? 0) / 1000) + 'k of ' + Math.round(context.window / 1000) + 'k'
    return Text({ color, children: ['context ' + bar(context.percent) + ' ' + context.percent + '% (' + used + ')'] })
  })
}

A drawing only changes when its hook runs again, so the mod calls $.ui.invalidate('ui.render') after each turn.10

The first test draws the band with $.ui.mount on both the terminal and desktop surfaces. With 84,000 of 200,000 tokens used, it draws context ████████░░░░░░░░░░░░ 42% (84k of 200k). The second test checks that nothing is drawn before the first reading.

Claude Code throttles redraws to 10 a second, or 30 in the terminal for a few sites, which is far more than this meter needs.6

8. Keep scratch notes in a pane that survives restarts

If you keep a running list of things to check while Claude works, put it in a pane.

/notes opens a pane with a text field. Each note gets an x button that deletes it, and the list is saved in $.store, a key-value store that lasts between sessions.10 This follows the notes example in the docs, with loading added.

const PANE = 'scratch-notes'

// The notes the pane draws, loaded from the store when the session starts
let notes = []

export function register(on) {
  on('session.start', async ($, e, next) => {
    const saved = await $.store.get('notes')
    if (Array.isArray(saved)) notes = saved
    await $.command.register({ name: 'notes', description: 'Open your scratch notes pane' })
    return next(e)
  })

  on('command.run', { command: 'notes' }, async ($) => {
    await $.ui.open({ id: PANE, title: 'Notes', focus: true, closeOnEscape: true })
    // Print nothing in the transcript
    return {}
  })

  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
    // Leave other mods' panes alone
    if (e.requestId !== PANE) return next(e)
    const { Box, Text, Button, Input } = $.ui.resolve(e)
    const save = async () => {
      $.ui.invalidate('ui.render')
      await $.store.set('notes', notes)
    }
    return Box({
      flexDirection: 'column',
      children: [
        Input({
          key: 'new-note',
          label: 'Note',
          placeholder: 'Type a note and press Enter',
          value: '',
          submitLabel: 'add',
          autoFocus: true,
          onSubmit: async (value) => {
            if (!value.trim()) return
            notes = [...notes, value.trim()]
            await save()
          },
        }),
        ...notes.map((note, i) =>
          Box({
            flexDirection: 'row',
            columnGap: 1,
            children: [
              Button({
                key: 'delete-' + i,
                label: 'x',
                plain: true,
                onPress: async () => {
                  notes = notes.filter((_, j) => j !== i)
                  await save()
                },
              }),
              Text({ children: [note] }),
            ],
          }),
        ),
      ],
    })
  })
}

The first test types a note, checks it's drawn, deletes it, and checks the saved list on both the terminal and desktop surfaces. A second test checks that notes saved earlier come back after session.start.

Keep in mind that $.store is shared by every session on your machine, and a get followed by a set isn't atomic, so two sessions can overwrite each other's change.10 The store holds up to 4 MiB of JSON.6

9. Give Claude a custom tool without running an MCP server

Teams often want Claude to look something up in one of their own systems.

$.tool.register adds a tool Claude can call. Claude sees it under a longer name, mcp__<plugin>__<tool>, and your tool.call hook for that full name answers it.9

This one fetches a GitHub issue. GitHub's REST docs say requests with no User-Agent header are rejected, which is why the mod sends one.11 An optional TICKET_API_BASE environment variable, read with $.env.get, points it at GitHub Enterprise or a test server.9

export function register(on) {
  on('session.start', async ($, e, next) => {
    await $.tool.register({
      name: 'github_issue',
      // Claude decides when to call the tool from this description
      description: 'Look up a GitHub issue or pull request and return its title, state, labels and first 1,500 characters',
      inputSchema: {
        type: 'object',
        properties: {
          repo: { type: 'string', description: 'owner/name, such as anthropics/claude-code' },
          number: { type: 'integer' },
        },
        required: ['repo', 'number'],
      },
    })
    return next(e)
  })

  // The full name is mcp__, the plugin's name, two underscores, and the tool's name
  on('tool.call', { tool: 'mcp__ticket-tool__github_issue' }, async ($, e) => {
    if (!/^[\w.-]+\/[\w.-]+$/.test(e.repo)) return { result: 'repo must look like owner/name' }
    // GitHub by default; set TICKET_API_BASE to point at GitHub Enterprise or a test server
    const base = (await $.env.get('TICKET_API_BASE')) ?? 'https://api.github.com'
    const url = base + '/repos/' + e.repo + '/issues/' + Number(e.number)
    const response = await $.http.fetch(url, { headers: { accept: 'application/vnd.github+json', 'user-agent': 'ticket-tool-mod' } })
    if (!response.ok) return { result: 'Lookup failed with status ' + response.status }
    let issue
    try {
      issue = JSON.parse(response.text)
    } catch {
      return { result: 'The lookup returned something that is not JSON' }
    }
    const labels = (issue.labels ?? []).map((l) => l.name).join(', ') || 'none'
    return {
      result: '#' + issue.number + ' ' + issue.title + '\nstate: ' + issue.state + '\nlabels: ' + labels + '\n\n' + (issue.body ?? '').slice(0, 1500),
    }
  }).catch(async ($, e, next) => {
    // A network error or a timeout becomes a result Claude can read
    return { result: 'The issue lookup failed: ' + next.error.kind }
  })
}

Two tests stub $.http.fetch: one checks the GitHub URL and the summary for a found issue, the other checks that TICKET_API_BASE changes the URL and that a 404 is reported.

Our first live run against api.github.com got HTTP 403, because our sandbox's network proxy only allows GitHub API calls for repositories attached to the session.

So we ran it live against a small local server that answers like GitHub's issues endpoint. Claude called the tool and replied with the title, state and labels the server sent (Export button does nothing, open, bug, p1), and the server log showed the request with the mod's User-Agent.

GitHub allows more requests per hour when you authenticate.11 For private repositories, read a token with $.env.get and send it as a header.9

10. Watch CI and show the result under the prompt

This one helps while you wait on a pull request's checks and keep working.

Work that outlives one event runs on a timer started in session.start. $.clock.every replaces setInterval, and $.ui.status sets one line under the prompt that stays until you change it.9

This mod asks the GitHub CLI for the current pull request's checks once a minute. gh pr checks --json includes a bucket field that sorts each check into pass, fail, pending, skipping or cancel.12

// Turn gh's JSON into a short line such as "3 pass · 1 fail · 2 pending"
function summarize(json) {
  const counts = {}
  for (const check of JSON.parse(json)) counts[check.bucket] = (counts[check.bucket] ?? 0) + 1
  const parts = ['pass', 'fail', 'pending'].filter((b) => counts[b]).map((b) => counts[b] + ' ' + b)
  return parts.length > 0 ? parts.join(' · ') : 'no checks'
}

async function refresh($) {
  let out
  try {
    out = await $.process.run(['gh', 'pr', 'checks', '--json', 'bucket,name'])
  } catch {
    $.ui.status(undefined)
    return
  }
  // gh exits 8 while checks are pending and 1 when the command fails,
  // so read the JSON instead of the exit code
  let line
  try {
    line = 'CI: ' + summarize(out.stdout)
  } catch {
    line = undefined
  }
  // undefined clears the line
  $.ui.status(line)
}

export function register(on) {
  on('session.start', async ($, e, next) => {
    // Check once a minute; the timer stops when the mod reloads
    $.clock.every(60_000, () => refresh($))
    return next(e)
  })
}

Two tests use the kit's mock.clock, so a minute passes instantly. After 59 seconds nothing shows. At 60 seconds the line reads CI: 2 pass · 1 pending. When there's no pull request, the line is cleared.

Timers stop when the mod reloads, and a new instance starts its own.910 The status line starts with a warning sign and the mod's name, so keep the text short.9

What changed in the first week

Mods arrived in 2.1.287 on October 1, 2026, and eight more releases followed by October 8.2 A few changes matter to mod authors:

VersionDateChange
2.1.287Oct 1, 2026"Added Claude Mods: plugins may now modify deeper behavior"
2.1.288Oct 2, 2026Added $.ui.selection(), which returns the text you last selected in fullscreen mode
2.1.290Oct 5, 2026claude plugin validate lists each hook registered at a gating site, and whether it has a .catch
2.1.292Oct 6, 2026Prompt caching for $.model.complete, and a new prompt.autocomplete event
2.1.293Oct 7, 2026isDeferred on $.tool.register, to list a tool's schema in the prompt from the start
2.1.295Oct 8, 2026Added $.ui.notify; a deny after a tool ran now says "the tool ran and a plugin withheld its result"

Source: Claude Code changelog.2 The 2.1.290 change is why our validate output includes lines such as gating hook with .catch: tool.call{tool=Bash}, which the docs' own example output doesn't show yet. The docs warn that events and methods can change between releases, and that the type declarations Claude Code writes for your version beat any page when the two disagree.4

Common mistakes and limits

claude plugin validate reads your source the way Claude Code does, and most first errors come from its rules:4

  • Write each API call in full, as in $.store.get('notes'). const ui = $.ui fails with $.ui is used as a value.
  • Use a string literal for the event name. A variable or a loop over names fails validation.
  • Register each event once per matcher. Two session.start hooks with no matcher stop the module from loading.5
  • Use ES modules. No require, and no import() calls.4

The limits that come up most:6

LimitValue
A hook's own running time for one event10 seconds
A .catch handler1 second
$.process.run timeout30 seconds by default, 10 minutes at most
$.model.complete maxTokens1,024 by default
$.fs.read and $.fs.write4 MiB per file
$.store4 MiB of JSON in total
One claude plugin test test5 seconds unless it sets timeoutMs

Time spent waiting inside next or a mods API call doesn't count toward the 10 seconds, except $.clock.sleep.6 That's why the confirm-risky guard can wait on $.ui.ask without running into the limit.5

Are Claude Code mods safe?

Mods are as safe as their author. The docs say a mod "runs with your permissions": it can read and write your files, start programs, make network requests, read your environment variables, see every prompt and tool call, approve a tool call before you're asked, and call a model on your plan. "Mods aren't sandboxed." Even with sandboxing on, a process a mod starts runs outside the sandbox.3

Two things a mod can't do: change what the permission prompt shows, or get past a block from a PreToolUse hook in your organization's managed settings, which run before any mod.35

Before you install someone else's mod, run claude plugin validate on its folder. The hooks: and calls: lines list the events it handles and every API call it makes, without running it.3 Our Plugin4Shell write-up covers why plugin trust matters for coding agents.

To switch mods off: disable one in /plugin, start a session with --safe-mode, or set "disableAllHooks": true in ~/.claude/settings.json.3

Controls for teams and organizations

On Team and Enterprise plans, and on machines with managed settings, a built-in guard named sec-default loads ahead of the mods a user installs, to stop them from doing risky things such as overriding permission deny rules.16 Administrators can also:6

  • run their own mods first or last with prependPlugins and appendPlugins
  • allow only the organization's mods with allowManagedModsOnly
  • reject --plugin-dir and --plugin-url at startup with disableSideloadFlags

The announcement's own team examples include CI/CD status in a pane, a confirmation before commands touch production config, and an audit log of every mod's calls.1

Where do mods run?

Mods hook events everywhere the plugin loads, but they only draw in some places.3

Where you run Claude CodeHooks runDrawings appear
claude in a terminalYesYes
Code tab of the Desktop app (not WSL sessions)YesYes, except terminal-only elements
VS Code extension's chat panelYesNo
claude -p and the Agent SDKYesNo
A cloud sessionYes, if the plugin reaches itNo

That's why six of our mods could be checked live in claude -p and the drawing ones couldn't.

More places to start

  • Built-in mods. Some of Claude Code's own features are mods, such as /diff, and you can see them under Built-in in /plugin. The source of several is in the mods folder of the Claude Code repository.3
  • Anthropic's samples. The claude-code-playground repository has token-weather (a context forecast above the prompt), blast-radius (holds a risky command and shows what it would change), and replay-theater (a /replay command for the last turn's edits). Anthropic shares them as they are, without support.3
  • Ask Claude. Describe the mod you want, and Claude writes it with the plugin-authoring skill. It runs once you approve it.4

Bottom line

You can now change how Claude Code behaves with a few dozen lines of JavaScript. The 10 examples here cover the common jobs: guard a command, change what Claude reads, add a command or a tool, and draw your own status. Each one is short, and claude plugin test lets you prove it works before it touches a real session. Read any mod you didn't write before you load it, because it runs as you.

References

Footnotes

  1. Customize Claude Code with mods, Anthropic, October 1, 2026. Source for "small TypeScript functions", what a mod can do with one function including redacting secrets from tool output, /diff shipping as a mod, sec-default on Team and Enterprise plans, the team examples, and the warning that mods aren't sandboxed. ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  2. Claude Code changelog, Anthropic, versions 2.1.287 (October 1, 2026) to 2.1.295 (October 8, 2026), read October 9, 2026. ↩ ↩2 ↩3 ↩4

  3. Mods overview, Claude Code docs, read October 9, 2026. Source for the definition, version requirements, the comparison table, what a mod can reach, the permission prompt, where mods run, turning mods off, built-in mods and the sample mods. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19

  4. Create a mod, Claude Code docs, read October 9, 2026. Source for the three files, no build step, --plugin-dir, the plugin-authoring skill, claude plugin validate and its rules, and the advice to trust the type declarations for your version. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11

  5. React to events with a mod, Claude Code docs, read October 9, 2026. Source for observe, rewrite and answer, tool.call, $.ui.ask, tool.check, prompt.submit context, turn.complete, managed PreToolUse hooks running first, failing hooks and .catch. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11

  6. Mods reference, Claude Code docs, read October 9, 2026. Source for files, $.session.usage(), limits, render sites and organization settings. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9

  7. Test a mod, Claude Code docs, read October 9, 2026. Source for claude plugin test, stubs, mock.clock, $.ui.mount and the exit status. ↩ ↩2 ↩3 ↩4

  8. Claude Code 2.1.295 type declarations for mods, ToolCallResult, the file Claude Code writes beside a mod as .claude-plugin/types/claude-code/index.d.ts. A published copy lives at mods/types/claude-code.d.ts in the Claude Code repository and can be older than your version. ↩

  9. Use the mods API, Claude Code docs, read October 9, 2026. Source for commands, immediate, tools and their mcp__ names, $.clock, $.ui.status, $.ui.log and $.env. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8

  10. Draw in the interface with a mod, Claude Code docs, read October 9, 2026. Source for render sites, the band above the prompt, panes, the notes example, redraws and $.store. ↩ ↩2 ↩3 ↩4 ↩5

  11. Getting started with the REST API, GitHub Docs, read October 9, 2026. Source for the required User-Agent header and higher hourly limits for authenticated requests. ↩ ↩2

  12. gh pr checks, GitHub CLI manual, read October 9, 2026. Source for the --json fields and the bucket values. ↩

Frequently Asked Questions

Claude Code mods are plugins whose JavaScript or TypeScript functions Claude Code calls when an event happens, such as a tool call, a prompt or a redraw. A mod can observe the event, rewrite it, or answer it, and it can draw panes, bands and status lines. Anthropic added them in version 2.1.287 on October 1, 2026.123