SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Claude Code Mod Examples and Writing Your Own

Three real Claude Code mods read hook by hook, then a small TypeScript mod that refuses VPS lockout commands, checked with plugin validate and plugin test.

Claude Code mod examples, and where they come from

The best Claude Code mod examples are the ones Anthropic publishes as source. These are the built-in mods in the Claude Code repository and the sample mods in its playground repository. Read two or three of them, then write a small mod of your own. This guide does both. The mod you build refuses Bash commands that would lock you out of a VPS (virtual private server).

A mod is a plugin with a hooks module. The hooks module is a JavaScript or TypeScript file, and Claude Code calls its functions when an event happens. If that idea is new to you, read what Claude Code mods are and what they can reach first, then come back.

Which Claude Code version do mods need?

Mods need Claude Code v2.1.287 or later. That comes from the official "Create a mod" page, read on 2026-10-05. This guide was written against Claude Code 2.1.289, and every type excerpt below comes from the declarations that version writes. Check your own version first:

claude --version

Events and methods change between releases. When Claude Code loads a mod from a folder, it writes .d.ts files (TypeScript declaration files) into the mod's .claude-plugin/types/ folder. They describe the exact events and methods of the version you run. When those files disagree with any web page, the files are right, and that includes this page. The main file is claude-code/index.d.ts. It is long, so search it for the name you need, such as 'tool.call'.

Where mods come from

A mod installs like any other plugin: from a marketplace. You have four sources.

  • The /plugin command and Anthropic's directory. Run /plugin in a session to browse and install. /plugin install token-chart@your-org installs a plugin named token-chart from a marketplace named your-org. Authors can submit a plugin to Anthropic's directory.
  • Your team's own marketplace repository. A private Git repository with one folder per plugin works as a marketplace. You can register it in a repository's settings, so everyone who works in that repository gets it.
  • Anthropic's sample mods. The claude-code/mods folder of the claude-code-playground repository holds sample mods, including blast-radius, covered below. They are shared as they are, without support.
  • The source of the built-in mods. The docs link to the mods folder of the Claude Code repository. It holds diff, agents-md, sec-default and telemetry. Each one is a complete plugin with a hooks module and tests.

A mod is a plugin, so the manifest, the marketplace file and the install scopes all work the way Claude Code plugins and marketplaces do. To see which mods are already running, open /plugin and go to the Installed tab. Built-in mods are listed under Built-in, with names such as cc-plugin-diff.

Before you load anyone's mod, read what it does without running it:

claude plugin validate ./some-mod

The section on validate below explains the output.

Three real mods, hook by hook

Every hook is registered with on(event, matcher, hook), and the matcher is optional. Every hook receives three arguments. $ is the mods API. e is the event, as frozen data. next passes the event on to the other mods and then to Claude Code's own behavior. A hook observes when it returns next(e) unchanged. It rewrites when it calls next with a changed copy of e. It answers when it returns a result and never calls next.

agents-md: load AGENTS.md as instructions

This built-in mod lets Claude Code read AGENTS.md files the way it reads CLAUDE.md. Its README lists four hooks:

  • session.start logs which instruction-file mode is set. The mode is a userConfig option, so you choose it in settings, not in code.
  • prompt.context loads AGENTS.md files from the folders above the working directory into the context sent with the first message.
  • agent.spawn copies the parent's prompt prefix to a forked subagent, so the fork reads the same instructions.
  • tool.call with the matcher { tool: 'Read' } attaches a nested AGENTS.md when Claude reads a file in a subfolder.

The last hook is the useful lesson. It does not block the Read call. It lets the call run and adds context, because the instructions for a subfolder only matter once Claude works in that subfolder.

blast-radius: hold a risky command and show what it would change

This sample mod intercepts shell commands such as rm -rf, git reset --hard, git clean and force pushes. Its tool.call hook on Bash does not call next at once. First it measures what the command would touch. It uses $.process.run with an argument list and no shell, so the measurement itself runs nothing dangerous. Then it opens a pane with Proceed and Cancel buttons and waits. The tool call stays pending until you press one. In a terminal narrower than 144 columns it draws in the band above the prompt instead, because a pane the user did not open only takes a seat in a terminal that wide.

This is the pattern to copy when a command is sometimes fine. The decision stays with the person, and the mod gives them facts to decide with. To try it, clone the repository and load the mod for one session:

git clone https://github.com/anthropics/claude-code-playground.git
cd claude-code-playground/claude-code/mods
claude plugin validate ./blast-radius
claude --plugin-dir ./blast-radius

sec-default: a policy mod with three moves

sec-default is the built-in guard. Its README says it makes only three moves. It can continue past the user tier with next.to(e, "append"). It can refuse a user-tier caller with { deny } or { refuse }. Or it can pass with next(e). Two of its hooks show the idea well:

  • tool.check keeps the deny rules from your settings in force over a user's mod, unless managed settings set allowModsToOverrideDenyRules.
  • plugin.register refuses a user's hooks module when managed settings set allowManagedModsOnly.

Every other event passes through it untouched, tool.call included. Read it when you want to see how a mod enforces a policy without getting in the way of everything else.

Write your own: a Bash deny-pattern mod for a VPS

The docs' tutorial mod counts tool calls. This mod does a different job. When Claude runs on a VPS, a few commands cut the server off from you: turning off the firewall, flushing every rule, stopping the SSH server, or rebooting in the middle of a job. The mod refuses those Bash calls and tells Claude why.

Create the folders:

mkdir -p vps-bash-guard/.claude-plugin vps-bash-guard/hooks vps-bash-guard/tests

The manifest goes in vps-bash-guard/.claude-plugin/plugin.json. Do not start the name with claude-, because claude plugin validate fails a name that looks like one of Anthropic's own.

{
  "name": "vps-bash-guard",
  "version": "0.1.0",
  "description": "Refuses Bash commands that would cut a VPS off from SSH or drop its firewall"
}

vps-bash-guard/hooks/hooks.json points at the code. The modules key is what makes the plugin a mod.

{
  "description": "The vps-bash-guard hooks module",
  "modules": ["./register.ts"]
}

The hooks module is vps-bash-guard/hooks/register.ts. Claude Code loads .ts files directly, so you need no build step.

import type { Register } from 'claude-code'

// Commands that lock you out of a remote server or take it offline
const DENY = [
  { pattern: /\bufw\s+(--force\s+)?(disable|reset)\b/, why: 'turns off the firewall' },
  { pattern: /\bnft\s+flush\s+ruleset\b/, why: 'removes every nftables rule' },
  { pattern: /\biptables\s+(-F|--flush)\b/, why: 'removes every iptables rule' },
  { pattern: /\bsystemctl\s+(stop|disable|mask)\s+(ssh|sshd)(\.service|\.socket)?\b/, why: 'stops the SSH server' },
  { pattern: /\b(reboot|poweroff|shutdown)\b/, why: 'takes the server offline' },
]

export const register: Register = on => {
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    const hit = DENY.find(rule => rule.pattern.test(e.command))
    if (hit) {
      return { deny: `${$.plugin.name}: this command ${hit.why} on a remote server. Explain the change and let the user run it.` }
    }
    return next(e)
  }).catch(async ($, e, next) => {
    return { deny: `${$.plugin.name}: the guard failed (${next.error.kind}), so the command was not run.` }
  })
}

Why each part has this shape

  • The matcher { tool: 'Bash' } means the hook runs only for Bash calls. The generated claude-code-tools/index.d.ts declares each built-in tool's input, so inside this hook e.command is typed as the shell command.
  • Returning { deny } without calling next answers the event. The command never runs, and no permission prompt appears. Claude reads the deny text as the tool's result, so the text is written as an instruction Claude can act on.
  • return next(e) passes every other command on to the permission check and then to Bash, unchanged.
  • .catch makes the guard fail closed. Without it, a hook that throws or runs past its time limit is skipped, which means the command it was checking would run.
  • DENY sits at the top of the file, outside register. Each reload runs register again and resets the module's variables. That is fine here, because the list never changes.

The ssh.socket part of the pattern matters on Ubuntu 24.04. There the SSH server is started by socket activation through ssh.socket, so the pattern covers that unit as well as ssh.service.

Check the signature against the generated types

Do not guess the hook signature. Load the mod once, and Claude Code writes the declarations next to it:

claude --plugin-dir ./vps-bash-guard

After the mod loads, vps-bash-guard/.claude-plugin/types/ holds claude-code/index.d.ts and a tsconfig.json. If the mod has no tsconfig.json of its own, Claude Code adds one at the mod's root that extends the generated one. Search for the three names this mod depends on:

grep -n "export type Register\|export type ToolCallResult\|export type HookFailure" vps-bash-guard/.claude-plugin/types/claude-code/index.d.ts

In 2.1.289 they say this. Register is (on: On, options: PluginOptions) => unknown. ToolCallResult is a union: either { deny: string } or { result, context? }, never both. HookFailure has kind: 'throw' | 'timeout', which is what next.error.kind holds in the .catch handler. If your version says something else, follow your version. To type-check the whole mod, run tsc -p ./vps-bash-guard on a machine with TypeScript installed.

What claude plugin validate prints

claude plugin validate ./vps-bash-guard

validate reads the manifest and runs the same static analysis on the hooks module that Claude Code runs when it loads a mod. It never executes your code. Two lines in its output matter most. For the docs' tool-call counter they read:

  ❯ ./register.js hooks: session.start, tool.call, command.run{command=tally}, ui.render{component=Spinner}
  ❯ ./register.js calls: $.command.register, $.ui.invalidate

The hooks: line lists every event the module registers, with its matcher in braces. For this guard, expect one entry: tool.call{tool=Bash}. If an event you meant to handle is missing from that line, Claude Code will not call that hook either. The usual cause is a misspelled event name, which validate reports as an error such as "tool.calls" is not an event.

The calls: line lists every mods API method the module calls. This is the line to read in someone else's mod. $.fs.read and $.fs.write reach files. $.process.run starts programs. $.http.fetch makes network requests. $.model.complete spends your plan's usage. The guard calls none of those. A module that reads environment variables or uses $.state also gets env reads: or state reads: lines.

Static analysis has to see every call, so it enforces a few rules. Write each call in full, starting with $: const ui = $.ui fails with $.ui is used as a value. Write each event name as a string literal, because a loop over names fails with the event name passed to on() is not a string literal. Use import declarations at the top of the file, never require or a dynamic import().

What claude plugin test checks

claude plugin test runs the mod's *.test.ts files without a session or a network connection. A test fires events at the mod's hooks and checks what came back. Save this as vps-bash-guard/tests/guard.test.ts:

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

test('refuses a command that stops the SSH server', async ($, on) => {
  // Answer each tool call in Claude Code's place, so no command runs
  on('tool.call', () => ({ result: 'ran' }))
  const answer = await $.tool.call({ tool: 'Bash', command: 'sudo systemctl stop ssh' })
  expect(answer.deny).toContain('stops the SSH server')
})

test('lets an ordinary command through', async ($, on) => {
  on('tool.call', () => ({ result: 'ran' }))
  const answer = await $.tool.call({ tool: 'Bash', command: 'df -h' })
  expect(answer.result).toBe('ran')
})

The on('tool.call', ...) line inside each test stands in for Claude Code's own behavior. Your mod's hook runs first. If it calls next, the call reaches the stub and comes back as { result: 'ran' }. If it denies, the stub never runs. So the first test proves the deny path, and the second proves that a normal command is not blocked by mistake.

Run the tests:

claude plugin test ./vps-bash-guard

The output names each test file, prints one line per test marked (pass) when it passes, and ends with the pass and fail counts. Add a test for every pattern you add to DENY. Add one for a near miss too, such as grep reboot /var/log/syslog. The last pattern blocks that harmless command, because it matches the word reboot anywhere in the line. That is a real false positive, so decide whether you accept it.

What a deny pattern can and cannot stop

This mod matches the text of a command. It is a reminder for Claude, not a security boundary. Claude can write the same command into a script and run the script, and the pattern never sees the inner command. The generated types make the same point about path guards: a deny-list on spellings is best effort. Keep the real protection outside the mod. Use permission deny rules in settings, and keep your provider's web console as a way back in when SSH fails.

Order matters too. PreToolUse hooks in managed settings run before any mod. PreToolUse hooks from your own settings files run after the last mod calls next. This guard answers without calling next, so those settings hooks never see a denied command. If you already block commands with a shell script, a PreToolUse settings hook does the same job without a mod. A mod earns its place when you want automated tests, or when the decision needs state that other hooks recorded.

Is a mod safe to run?

Mods are not sandboxed. A mod runs inside Claude Code with Claude Code's own access, so it can read your files and start processes that reach the network. Claude Code's sandbox isolates the Bash commands Claude runs, and a process a mod starts runs outside it. On a machine with managed settings, or for a user signed in on a Team or Enterprise plan, the built-in sec-default guard loads ahead of every mod a user installs, but it only protects what the organization manages. For the full review checklist, read how to decide whether to trust a Claude Code mod. For the server side, read how to run Claude Code safely on a VPS.

Are Claude Code themes a mod job?

No. Theming is a built-in setting. Run /theme, or use the theme picker in /config, to choose a preset. A custom theme is a JSON file in ~/.claude/themes/. It sets a base preset and an overrides map of color tokens, plus an optional display name. This example from the docs keeps the dark preset and recolors three tokens. Save it as ~/.claude/themes/dracula.json:

{
  "name": "Dracula",
  "base": "dark",
  "overrides": {
    "claude": "#bd93f9",
    "error": "#ff5555",
    "success": "#50fa7b"
  }
}

Claude Code watches that folder and reloads the theme when the file changes. Plugins can also ship themes as a plugin component, with no hooks module at all. A mod touches themes in only two small ways. The Text elements a mod draws take a theme color key, such as error or promptBorder, so they follow the user's theme. A config.set hook with the matcher { key: 'theme' } can also refuse a theme change.

A line of text at the bottom of the screen does not need a mod either. A custom Claude Code status line is a script that prints text, and it is simpler to keep working across releases.

Keep the mod and share it

Copy the folder somewhere permanent, such as ~/mods/vps-bash-guard, and start sessions with claude --plugin-dir ~/mods/vps-bash-guard. If you run Claude Code on the server inside a terminal multiplexer, start it with the same flag in the session you keep open, as in running Claude Code on a VPS with tmux. To share the mod with a team, list it in your marketplace repository. Keep developing against the folder, not an installed copy, because Claude Code caches an installed plugin by version. Your edits do not reach the installed copy until you raise the version and install again. Write the Claude Code version you tested in the README, because the next release may change an event you use.

FAQ

Why does my Claude Code mod's hook never run?

Run claude plugin validate on the mod's folder and read the hooks: line. An event that is missing from it is not registered, usually because the name is misspelled, which validate reports as an error such as "tool.calls" is not an event. If the line is correct, open /plugin and check the dim line under the tabs, which names the mods the session loaded. A session started with --safe-mode, or with disableAllHooks set, loads no installed mods. In a folder you have not trusted yet, no mod loads until you accept the trust prompt.

Can a mod block a command that a permission rule allows?

Yes. A tool.call hook that returns { deny } stops the call before the permission check runs, whatever your allow rules say. The other direction is limited. Where the built-in sec-default guard loads, a user's mod cannot approve a call that a deny rule refuses.

Do I need Node.js or a build step to write a mod?

No. Claude Code loads .js and .ts hooks modules directly, and it needs to be v2.1.287 or later. You only need TypeScript installed if you want to run tsc against the tsconfig.json that Claude Code generates for the mod.

Will a mod written today work after the next Claude Code update?

Not always. The generated declaration file opens with a note that the surface may change between releases without notice. After each update, load the mod once so Claude Code rewrites the .d.ts files. Then run claude plugin validate and claude plugin test, and record the version you tested in the README.