Claude Code Mods Tutorial: Build a Guard Mod That Intercepts Tool Calls

Introduction

Claude Code already had plugins, skills, commands, and settings hooks, but none of them could reach inside the agent loop. Mods change that: a mod is a small TypeScript or JavaScript function that hooks into events Claude Code fires while it works, and it can observe, rewrite, or answer each event before the agent acts. In this tutorial you will build a working guard mod that auto-approves read-only tool calls, asks you to confirm risky Bash commands before they run, and redacts secrets from tool output before Claude reads them. For guard hooks specifically, make sure a hook failure blocks the action instead of letting it through silently: our Claude Code hooks fail-closed tutorial shows how to configure onFailure: block and tune hook timeouts.

Quick answer

Package a mod as a plugin: hooks/hooks.json plus register.ts exporting register(on, options). Add handlers with on(event, matcher, ($, e, next) => {…}), then validate with claude plugin validate, test with claude plugin test, and load it with claude –plugin-dir ./your-plugin.

Why mods matter: the evidence

Claude Code 2.1.287 was published to npm on October 1, 2026 at 16:59 UTC, and the changelog entry reads “Added Claude Mods: plugins may now modify deeper behavior” (reported by mixed-news from the release). In the launch post, Anthropic writes that “a mod can rewrite a prompt, add new UI, replace a built-in feature, or add entirely new functionality” (quoted by AI Weekly from the official announcement).

The strongest signal that mods are load-bearing is what Anthropic built with them. The official anthropics/claude-code repository publishes the source of the four mods that ship inside Claude Code: sec-default (the organizational guard), diff (the /diff pane), telemetry (first-party analytics), and agents-md (AGENTS.md support). The built-in /diff feature now ships as a mod, which means you can disable it or replace it with your own implementation through the plugin interface.

The official mods overview states the core mechanic in one line: “Your hook runs before Claude Code acts, so it can observe the event, rewrite it, or answer it in Claude Code’s place.” The events documentation adds the trust framing: hooks on the same event form a middleware chain ordered by where each mod comes from, and the first mod in the chain sees the event first and the result last.

Prerequisites

  • Claude Code 2.1.287 or later installed (claude --version to check). Mods are enabled by default; no flag needed.
  • Node.js 18+ on your machine (mods are plain TypeScript, and the worked examples run without a build step).
  • Basic TypeScript: you will write one register.ts file.
  • Familiarity with Claude Code’s permission system helps. Our Claude Code MCP setup guide shows what tool calls look like in a session, and our guide to connecting an MCP server to Claude Desktop covers the desktop surface mods also run on.

Step 1, Understand where a mod sits: mods vs plugins vs hooks vs skills

Before writing code, get the layering right, because the names overlap.

  • Plugin: the installable, versioned unit. A plugin bundles skills, agents, settings hooks, MCP servers, and, since 2.1.287, a hooks module. A mod is a plugin whose behavior lives in that hooks module: one register(on, options) entry whose functions Claude Code calls with ($, e, next).
  • Mod (hooks module): runs inside Claude Code’s own process and hooks the engine’s events. It can rewrite prompts, block or retry tool calls, approve or refuse permission decisions, redact tool output, and redraw interface elements. This is the layer you are building.
  • Settings hooks: the older mechanism. A settings hook is an external process fed JSON on stdin at defined points (PreToolUse, Stop, SessionEnd). Mods do not replace them.
  • Skill: a Markdown instruction file that tells Claude how to do something. Skills change what Claude knows; mods change what Claude Code does.

The practical consequence: where a PreToolUse hook can only approve or deny a call from outside the process, a mod’s tool.call hook can also rewrite arguments, retry the call, answer it with its own result, or withhold the result from Claude after the tool ran.

Step 2, Scaffold the mod as a plugin

A mod is a complete plugin folder. Create this layout:

session-guard/
  .claude-plugin/plugin.json
  hooks/hooks.json
  hooks/register.ts
  tests/register.test.ts

The plugin manifest is minimal:

{
  "name": "session-guard",
  "description": "Auto-approve read-only tools, confirm risky Bash commands, and redact secrets from tool output.",
  "version": "1.0.0"
}

The hooks manifest names the module, exactly as the official samples do:

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

Anthropic publishes the four built-in mods in exactly this layout in the official mods folder, where you can read them as reference implementations.

Step 3, Auto-approve read-only tools with tool.check

The permission decision is its own event. Handle tool.check, which fires after the permission rules and settings hooks have decided, and next(e) resolves to their decision: allow, ask, or deny. Your hook returns that decision or a different one. This is the documented way to approve a call that would otherwise prompt, including one a PreToolUse hook blocked.

Add this first handler to hooks/register.ts:

export function register(on) {
  // Auto-approve read-only tools: if the decision is 'ask', answer 'allow'
  on('tool.check', { tool: ['Read', 'Glob', 'Grep'] }, async ($, e, next) => {
    const decision = await next(e)
    return decision === 'ask' ? 'allow' : decision
  })
}

The second argument is a matcher: an object whose fields are compared with the event’s fields, and the hook runs only when every field matches. A field can be a value, an array, or a regular expression. The official docs say to register each event once per matcher rather than calling on twice for the same event without one.

This handler is conservative on purpose: it never turns a deny into an allow, and only upgrades ask to allow for tools that cannot modify anything. Our pillar guide to building an MCP server from scratch covers the read vs write distinction from the server side.

Step 4, Gate risky Bash commands with $.ui.ask

A tool.call hook sees each tool Claude is about to use: e.tool is the tool’s name and the tool’s arguments are fields of e, such as e.command for Bash. When you call next(e), Claude Code runs the permission check and then the tool. If you never call next and return { deny: reason } instead, the command does not run and Claude reads your reason as the tool’s result. A hook can also pause the call and ask the user: $.ui.ask shows your question above a numbered list of options in the dialog Claude uses to ask you something, and resolves to the label the user picks. It rejects when the user dismisses the question, picks Chat about this, or when the run is headless (claude -p).

const RISKY = /\brm\s+(-[a-z]*r|--recursive)\b|\bgit\s+push\s+.*--force\b|\bgit\s+reset\s+--hard\b/

export function register(on) {
  // ... Step 3 handler above ...

  // Ask the user before a risky Bash command runs
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    if (!RISKY.test(e.command ?? '')) return next(e)

    let choice: string
    try {
      choice = await $.ui.ask({
        question: `Claude wants to run this command:\n${e.command}`,
        options: ['Run it', 'Refuse'],
      })
    } catch {
      choice = 'Refuse' // dismissed, Chat about this, or claude -p: refuse by default
    }

    if (choice === 'Run it') return next(e)
    return { deny: 'The user refused this command. Do not retry it; suggest a safer alternative instead.' }
  })
}

Three details from the official docs are load-bearing here. First, the wait must happen inside $.ui.ask: hooks have a time limit, time spent inside a mods API call does not count against it, and Claude Code skips a hook that times out, so a timed-out gate would let the command run. Second, when the user picks Run it, the usual permission check still runs after your hook, so this gate adds a layer rather than replacing permissions. Third, the matcher pattern deliberately misses some spellings, such as git push -f; extend the pattern for the commands your team actually runs.

Step 5, Redact secrets from tool output

A post-run tool.call hook can clean tool output before Claude reads it. The pattern below wraps Bash and Read calls, lets the tool run, then returns a redacted copy of what came back:

const PATTERNS: RegExp[] = [
  /sk-[A-Za-z0-9_-]{20,}/g, // OpenAI- and Anthropic-style API keys
  /AKIA[0-9A-Z]{16}/g, // AWS access key IDs
  /gh[pousr]_[A-Za-z0-9]{36,}/g, // GitHub tokens
]

function redactStrings(value: unknown, hits: { count: number }): unknown {
  if (typeof value === 'string') {
    let out = value
    for (const pattern of PATTERNS) {
      out = out.replace(pattern, () => {
        hits.count += 1
        return '[REDACTED]'
      })
    }
    return out
  }
  if (Array.isArray(value)) return value.map((item) => redactStrings(item, hits))
  if (value && typeof value === 'object') {
    return Object.fromEntries(
      Object.entries(value).map(([key, item]) => [key, redactStrings(item, hits)]),
    )
  }
  return value
}

export function register(on) {
  // ... Step 3 and Step 4 handlers above ...

  // Redact secrets from Bash and Read output before Claude reads them
  on('tool.call', { tool: ['Bash', 'Read'] }, async ($, e, next) => {
    const result = await next(e)
    if (result.deny) return result // leave refused calls alone

    const hits = { count: 0 }
    const cleaned = redactStrings(result, hits)
    if (hits.count === 0) return result

    // A dim line in the transcript that Claude doesn't read
    $.ui.log(`guard: hid ${hits.count} secret(s) from ${e.tool} output`)
    return cleaned
  })
}

Two things here are easy to get wrong, and both are documented. First, the event is deeply frozen: assigning to one of its fields throws, so the hook builds a copy of the result rather than editing it in place. Second, the real tool result is an object where text holds what Claude reads and result.stdout holds the tool’s structured output; redacting every string in the result, not one assumed field, is what makes the hook actually work. The $.ui.log call adds a dim transcript line telling you something was hidden, without telling Claude. Note the chain order: hooks on the same event form a middleware chain in registration order, so the gate asks its question before the redaction hook ever sees the output.

Step 6, Validate and test without starting a session

Claude Code’s test kit fires events through your hooks with no model, network, or sign-in. First, validate the plugin: claude plugin validate lists every event the mod handles and every mods-API call it makes, the same view a reviewer gets before installing it:

cd session-guard
claude plugin validate .

Expected output names your hooks and API calls:

  ❯ ./register.ts hooks: tool.check{tool=Read|Glob|Grep}, tool.call{tool=Bash}, tool.call{tool=Bash|Read}
  ❯ ./register.ts calls: $.ui.ask, $.ui.log
  ✔ Validation passed

Then add tests. Each test stubs what Claude Code would answer and checks what the mod returns. Save this as tests/register.test.ts:

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

test('an API key in Bash output is replaced', async ($, on) => {
  on('tool.call', () => ({
    result: { stdout: 'OPENAI_API_KEY=sk-test-abcdefghijklmnopqrstuvwx' },
    text: 'OPENAI_API_KEY=sk-test-abcdefghijklmnopqrstuvwx',
  }))
  on('ui.log', () => ({ value: undefined }))

  const result = await $.tool.call({ tool: 'Bash', command: 'cat .env' })

  expect(result.text).not.toContain('sk-test')
  expect(result.result.stdout).not.toContain('sk-test')
})

test('output without secrets passes through unchanged', async ($, on) => {
  on('tool.call', () => ({ result: { stdout: 'hello' }, text: 'hello' }))

  const result = await $.tool.call({ tool: 'Bash', command: 'echo hello' })

  expect(result.text).toBe('hello')
})

test('a refused call is left alone', async ($, on) => {
  on('tool.call', () => ({ deny: 'the user refused this command' }))

  const result = await $.tool.call({ tool: 'Bash', command: 'rm -rf dist' })

  expect(result.deny).toBeDefined()
})

Run them from the plugin directory:

claude plugin test

You should see all tests pass. The hooks the test registers with on sit beneath the mod, where the rest of the world would be, and nothing is beneath them. This is also how the built-in mods are tested in the official repository (claude plugin test mods/diff).

Step 7, Run it in a real session

The test kit checks your logic; only a live run checks the shape of what Claude Code really sends. Make a throwaway directory with a fake key, then load the mod for one session with --plugin-dir:

mkdir -p /tmp/mod-lab && cd /tmp/mod-lab
echo 'OPENAI_API_KEY=sk-test-abcdefghijklmnopqrstuvwx' > fake.env
claude -p "Use the Bash tool to run exactly: cat fake.env. Then reply with the tool output verbatim, nothing else." \
  --plugin-dir ~/session-guard --allowedTools "Bash(cat:*)" --model haiku

With the mod loaded, Claude’s reply shows the redacted line:

OPENAI_API_KEY=[REDACTED]

Now test the gate headlessly. In a claude -p run there is no interactive dialog, so $.ui.ask rejects and the hook refuses by design:

claude -p "Use the Bash tool to run exactly: rm -rf /tmp/mod-lab. Do not do anything else." \
  --plugin-dir ~/session-guard --allowedTools "Bash" --model haiku

Claude should report that the command was refused by the mod rather than showing deletion output. In an interactive session (claude --plugin-dir ~/session-guard), the risky command instead shows your confirmation dialog, and Claude Code reloads the module when you save a change to it.

When you are happy with it, publish to a plugin marketplace and install with claude plugin install. For a team rollout, ship it through managed settings so users cannot remove it, and run /reload-plugins in an open session after installing. For a broader look at agents once the guardrails are in place, our Nous Hermes agent tutorial walks through a full agent build on a different stack.

Troubleshooting

The risky command ran even though the mod was loaded. Your hook probably timed out. Hooks have a time limit, and Claude Code skips a hook that exceeds it, so the held command runs. Keep the wait inside a mods API call such as $.ui.ask, whose time does not count against the limit, instead of awaiting a promise of your own.

The module fails to load with on("session.start") is registered twice without a matcher. You called on twice for the same event with no matcher. Register each event once per matcher and combine everything the mod does at session start into one hook.

Assigning to e.command or e.text throws. Events arrive deeply frozen by design. Never mutate them: to change what Claude Code acts on, call next with a modified copy (next({ ...e, text: newText })), and to change a result, await next(e) and return a copy with the field replaced.

In claude -p runs, the gate refuses everything. That is designed behavior, not a bug. $.ui.ask rejects when there is no dialog to show (dismissed, Chat about this, or a headless run), and the catch block defaults to Refuse, which is the safe default for automation.

Claude still saw the API key even though the tests passed. Your test stubbed the wrong result shape. The real tool result is an object: text holds what Claude reads and result.stdout holds the tool’s structured output. A test that stubs a plain string passes and still leaks live. Redact every string in the result recursively, and re-run one live session against a fake key before you trust the suite. Remember the matcher gap too: this mod watches Bash and Read, so a key arriving through Grep, a web fetch, or an MCP tool passes through untouched unless you extend the matcher.

Should I install someone else’s mod? Treat it as installing code, because that is what it is. Mods are not sandboxed and run with your permissions. Before installing, run claude plugin validate on it and read every event and every $.ui.* call it lists. For organizations, managed settings decide which mods may load.

FAQ

What Claude Code version do I need for mods? Claude Code 2.1.287 or later, released on October 1, 2026. Mods are enabled by default from that version in both the CLI and the desktop app’s Code tab; there is no flag to turn on.

Do mods replace plugins, skills, or settings hooks? No. Mods ship inside plugins, alongside skills, agents, commands, settings hooks, and MCP servers. Settings hooks remain first-class: they are separate processes fed JSON on stdin, and mods neither replace nor deprecate them.

Are Claude Code mods sandboxed? No. Mods run with the same permissions as Claude Code itself: they can read and write your files, start processes, and make network requests. Anthropic’s docs say to install mods only from authors and marketplaces you trust, and claude plugin validate lists every event a mod handles and every mods-API call it makes so you can review it first.

Can a mod override my organization’s permission rules? No. The built-in guard mod sec-default and your organization’s managed-settings hooks run first in the chain, and a block from managed settings is final. A mod can approve a call that a PreToolUse hook outside managed settings blocked, but it cannot override a managed denial.

Why can’t my mod restyle the permission prompt? Anthropic draws one hard UI boundary: a mod can restyle much of Claude Code’s interface, but not the permission prompt. The dialog that asks you to approve a command stays Claude Code’s own, whatever else a mod redraws.

Why did my risky command run even though my mod was loaded? Most likely the hook timed out. Hooks have a time limit, and Claude Code skips a hook that exceeds it, so the held command runs. Keep the wait inside a mods API call such as $.ui.ask, because time spent there does not count against the limit, while time awaiting a promise of your own does.

Conclusion

You now have a working Claude Code mod that auto-approves read-only tools, asks before risky Bash commands run, and redacts secrets from tool output, with a test suite that needs no model. The deeper lesson is architectural: mods sit in a middleware chain in front of Claude Code’s own behavior, ordered by where each mod comes from, and that chain is where guardrails belong. Next, wire external tools into the guarded sessions with our Claude Code MCP setup guide, and build the server side with our pillar guide to building an MCP server from scratch.

Frequently asked questions

What Claude Code version do I need for mods?

Claude Code 2.1.287 or later, released on October 1, 2026. Mods are enabled by default from that version in both the CLI and the desktop app's Code tab; there is no flag to turn on.

Do mods replace plugins, skills, or settings hooks?

No. Mods ship inside plugins, alongside skills, agents, commands, settings hooks, and MCP servers. Settings hooks remain first-class: they are separate processes fed JSON on stdin, and mods neither replace nor deprecate them.

Are Claude Code mods sandboxed?

No. Mods run with the same permissions as Claude Code itself: they can read and write your files, start processes, and make network requests. Anthropic's docs say to install mods only from authors and marketplaces you trust, and `claude plugin validate` lists every event a mod handles and every mods-API call it makes so you can review it first.

Can a mod override my organization's permission rules?

No. The built-in guard mod sec-default and your organization's managed-settings hooks run first in the chain, and a block from managed settings is final. A mod can approve a call that a PreToolUse hook outside managed settings blocked, but it cannot override a managed denial.

Why can't my mod restyle the permission prompt?

Anthropic draws one hard UI boundary: a mod can restyle much of Claude Code's interface, but not the permission prompt. The dialog that asks you to approve a command stays Claude Code's own, whatever else a mod redraws.

Why did my risky command run even though my mod was loaded?

Most likely the hook timed out. Hooks have a time limit, and Claude Code skips a hook that exceeds it, so the held command runs. Keep the wait inside a mods API call such as `$.ui.ask`, because time spent there does not count against the limit, while time awaiting a promise of your own does.

TP
ToolPilot Team

We install, break, and fix automation tools so you don't have to. Every tutorial on ToolPilot is tested hands-on before publishing.

Keep reading