AI DevelopmentPlaybook5 min readPublished October 3, 2026

A secret-redacting mod, built, tested and run on Claude Code 2.1.288

How to Build Your First Claude Code Mod: A Worked Example

A step-by-step Claude Code Mod that redacts secrets from tool output: project layout, the TypeScript hook, local testing, enabling it and sharing it safely.

DA
Digital Applied Team
Research and practical guidance
CoverageOctober 3, 2026

Claude Code 2.1.287 added Mods on October 1, 2026: plugins whose JavaScript or TypeScript functions run inside Claude Code and can change what happens on events such as a tool call. This tutorial builds one that hides API keys and private keys from the output of shell commands and file reads before Claude sees them. We built and ran every step on Claude Code 2.1.288 on October 3, including a bug our tests missed and a live session caught.

Key takeaways
  1. 01
    Three filesA manifest, a hooks.json that points to your code, and one TypeScript module that registers the hook.
  2. 02
    One hook does itA tool.call hook lets the tool run, then returns a redacted copy of the result.
  3. 03
    Test, then run liveclaude plugin test passed on our first version, but a real session still leaked the key.
  4. 04
    Not a sandboxA mod runs with your permissions. Review one before you install it.

01 — ContextWhat we are building, and why a mod

When Claude runs cat .env or reads a config file, the output goes into the conversation, keys included. Our mod sits around every Bash and Read call, lets the tool run, and replaces anything that looks like a secret with [REDACTED] before Claude reads the result. The secret stays in the file on disk; Claude never sees it.

Claude Code’s older settings hooks can also replace a tool’s output, so a mod is not the only way to do this. A mod has two advantages for a job like this: the logic is ordinary TypeScript in one file, and Claude Code ships a test kit that runs it without a session. Our explainer on Claude Code Mods covers what else they can do, and our comparison of hooks across 12 coding agents shows which other tools can rewrite output the same way.

A
Settings hook
Shell command in settings.json

A script that reads JSON and prints a decision. Works in any language; tested by running the script.

Outside Claude Code
B
Mod
TypeScript function in a plugin

A function Claude Code calls in its own process, with a test kit and access to the interface.

Inside Claude Code

02 — Step 1The three files, plus a test

A mod is a plugin directory. Mods need Claude Code 2.1.287 or later; run claude --version to check. Create this layout anywhere outside your project:

redact-secrets/
├── .claude-plugin/
│   └── plugin.json
├── hooks/
│   ├── hooks.json
│   └── register.ts
└── tests/
    └── redact.test.ts

The manifest, .claude-plugin/plugin.json, needs no fields specific to mods:

{
  "name": "redact-secrets",
  "version": "0.1.0",
  "description": "Replaces API keys and private keys in tool output before Claude reads them",
  "author": { "name": "Your Name" }
}

hooks/hooks.json is what makes the plugin a mod. Its modules key points to the code file, and the mods reference accepts a TypeScript file; our .ts module ran without a build step:

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

03 — Step 2The hook: let the tool run, then clean the result

Save this as hooks/register.ts. Claude Code calls register once when the mod loads, and the tool.call hook runs around every Bash and Read call. Calling await next(e) runs the permission check and the tool; the hook then returns a redacted copy of what came back.

// Patterns for common secret formats. Extend this list for your own keys.
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
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
]

// Replace secrets in every string inside a value, counting replacements
function redact(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) => redact(item, hits))
  if (value && typeof value === 'object') {
    return Object.fromEntries(
      Object.entries(value).map(([key, item]) => [key, redact(item, hits)]),
    )
  }
  return value
}

export function register(on) {
  // Runs around every Bash and Read call
  on('tool.call', { tool: ['Bash', 'Read'] }, async ($, e, next) => {
    // Let the tool run, then inspect what it returned
    const result = await next(e)
    // Leave refused calls alone
    if (result.deny) return result
    const hits = { count: 0 }
    // Build a redacted copy rather than editing the result in place
    const cleaned = redact(result, hits)
    if (hits.count === 0) return result
    // A dim line in the transcript that Claude doesn't read
    $.ui.log('redact-secrets: hid ' + hits.count + ' secret(s) from ' + e.tool)
    return cleaned
  })
}

Three details matter. The event arrives frozen, and the hook builds a copy of the result rather than editing it in place. A refused call comes back as { deny } and is left alone. And $.ui.log adds a dim line to the transcript that tells you something was hidden, without telling Claude.

The bug our tests missed

Our first version assumed the result’s result field was a string. The test kit happily accepted that, both tests passed, and a live run still showed Claude the key. In a real session the result is an object: text holds what Claude reads and result holds the tool’s structured output, as below. Redacting every string in the result fixed it.

{
  "ref": 1,
  "result": {
    "stdout": "APP_NAME=demo\nOPENAI_API_KEY=sk-fake…\nDEBUG=true",
    "stderr": "",
    "interrupted": false,
    "isImage": false,
    "noOutputExpected": false
  },
  "text": "APP_NAME=demo\nOPENAI_API_KEY=sk-fake…\nDEBUG=true",
  "isReadOnly": true
}

04 — Step 3Test it without starting a session

Claude Code’s test kit fires events through your hooks with no model, network or sign-in. Each test stubs what Claude Code would answer, here a tool result shaped like the real one, and checks what the mod returns. Save this as tests/redact.test.ts:

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

test('an API key in Bash output is replaced', async ($, on) => {
  // Stand in for Bash: the "tool" returns a line containing a key
  on('tool.call', () => ({
    result: { stdout: 'OPENAI_API_KEY=sk-test1234567890abcdefghijklmn' },
    text: 'OPENAI_API_KEY=sk-test1234567890abcdefghijklmn',
  }))
  // Answer the mod's $.ui.log call
  on('ui.log', () => ({ value: undefined }))
  const result = await $.tool.call({ tool: 'Bash', command: 'cat .env' })
  expect(result.text).toBe('OPENAI_API_KEY=[REDACTED]')
  expect(result.result.stdout).toBe('OPENAI_API_KEY=[REDACTED]')
})

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')
})

Then validate the plugin and run the tests from its directory. claude plugin validate also lists every event the mod handles and every API call it makes, which is the same view a reviewer gets before installing it.

cd redact-secrets
claude plugin validate .
claude plugin test
  ❯ ./register.ts hooks: tool.call{tool=Bash|Read}
  ❯ ./register.ts calls: $.ui.log
✔ Validation passed

 2 pass
 0 fail
Ran 2 tests across 1 file.

05 — Step 4Run it in a real session

Make a throwaway directory with a fake key in a file, for example OPENAI_API_KEY=sk-fake followed by enough characters to look real, and load the mod for one session with --plugin-dir. We used headless mode so the result is easy to read:

claude -p "Use the Bash tool to run exactly: cat fake.env  Then reply with the tool output verbatim, nothing else." \
  --plugin-dir ./redact-secrets --allowedTools "Bash(cat:*)" --model haiku

Claude’s reply, with the mod loaded:

APP_NAME=demo
OPENAI_API_KEY=[REDACTED]
DEBUG=true

We repeated the run with the Read tool instead of Bash and got the same redacted output. In an interactive session started with claude --plugin-dir ./redact-secrets, the mod’s $.ui.log call adds a dim line to the transcript, and Claude Code reloads the module when you save a change to it.

06 — Practical implicationsEnable it, share it, and know its limits

Trying it yourself
Load it for one session with --plugin-dir
No install
Using it every day
Publish to a plugin marketplace, then claude plugin install
Installed
Rolling it out to a team
Ship it through managed settings so users cannot remove it
Organisation
Installing someone else’s mod
Run claude plugin validate on it and read every call first
Review

Know what this mod does not do. It only catches the formats in its pattern list, and only on Bash and Read; Grep, web fetches and MCP tools pass through untouched unless you add them to the matcher. The secret is still in the file and in your terminal. And Claude Code’s documentation is plain that mods aren’t sandboxed: the code runs with your permissions, so a mod you install from someone else can read the same secrets this one hides. Our security checklist for Claude Code Mods covers what to check before installing one. For teams that want guardrails like this designed and rolled out across their coding agents, our AI transformation team can help.

Next step

Add your own key formats and run it live

Copy the three files, add a pattern for each key format your team actually uses, and write a test for each. Then run one real session against a fake key before you trust it: the test kit checks your logic, and only a live run checks the shape of what Claude Code really sends.

Agentic AI implementation

Guardrails for the coding agents your team runs

Digital Applied builds and tests mods and hooks that keep secrets, branches and production systems safe across Claude Code and other agents.

Secret redactionCommand guardsTested rollouts
Before you ship a mod

Check four things

  • →Tests for each pattern
  • →One live session run
  • →The validate output
  • →Who can turn it off
Questions and answers

Practical questions

Claude Code 2.1.287 or later, released October 1, 2026. Mods are on by default from that version. We built this example on 2.1.288.
Digital Applied newsletter

Deep dives on AI, marketing and development.

Practical guides and fresh insights by email. No recycled takes.

Related dispatches

Continue reading

AI Development

Which AI Coding Agents Let You Intercept Tool Calls?

Claude Code, Codex, Cursor, Gemini CLI, Copilot and seven more compared: which events each hook system exposes, what it can block or rewrite, where it runs.

October 3, 2026 · 6 minRead
AI Development

Before You Install a Claude Code Mod: A Security Checklist

Claude Code Mods can rewrite prompts, approve tool calls and read secrets. A checklist for reviewing a third-party mod before you or your team switch it on.

October 2, 2026 · 7 minRead
AI Development

Claude Code Mods: What Function Hooks Can Change in an Agent

Claude Code 2.1.287 adds Mods, plugins that wrap prompts, tool calls and permissions in TypeScript. What they can change, how to enable them and the risks.

October 1, 2026 · 8 minRead
AI Development

Codex After DevDay: Cloud Tasks, Code Review and Security

What DevDay changed in Codex: cloud tasks, a refreshed CLI, automatic code review on pull requests and Codex Security Cloud, and who gets each feature.

September 29, 2026 · 5 minRead
AI Development

What a "14 Times Faster" AI Claim Actually Measures

Two 14x claims and one 35x claim landed in one month, measuring three different things. A speedup is a ratio, and the matched run is never in the headline.

August 30, 2026 · 18 minRead
AI Development

Preview, Beta, GA: What Vendors Said vs What Coverage Said

Thirty-six AI vendor announcements from 17-22 August 2026, each scored on the vendor's own status word against the word its coverage used, where located.

August 22, 2026 · 27 minRead
Google Search

See more Digital Applied analysis in your Google results by adding us as a preferred source.

Add as a preferred source