cd /news/ai-tools/how-to-build-your-first-claude-code-… · home › topics › ai-tools › article
[ARTICLE · art-144426] src=digitalapplied.com ↗ pub= topic=ai-tools verified=true sentiment=· neutral

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

Claude Code 2.1.287, released October 1, 2026, introduced Mods — plugins whose JavaScript or TypeScript functions run inside Claude Code and can alter behavior on events such as a tool call. A worked tutorial built a redact-secrets mod on Claude Code 2.1.288 on October 3 that intercepts every Bash and Read call via a tool.call hook and replaces API keys, AWS access key IDs, GitHub tokens and private keys with [REDACTED] before Claude reads the output, though the author notes the plugin test kit passed the first version while a live session still leaked a key, and warns that a mod runs with the user's own permissions rather than in a sandbox.

read7 min views1 publishedOct 3, 2026
How to Build Your First Claude Code Mod: A Worked Example
Image: Digitalapplied (auto-discovered)

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.

  1. 01Three filesA manifest, a hooks.json that points to your code, and one TypeScript module that registers the hook.
  2. 02One hook does itA tool.call hook lets the tool run, then returns a redacted copy of the result.
  3. 03Test, then run liveclaude plugin test passed on our first version, but a real session still leaked the key.
  4. 04Not 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.

Settings hook

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

Mod

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

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.

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 #

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.

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.

── more in #ai-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-to-build-your-fi…] indexed:0 read:7min 2026-10-03 · —