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. 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 https://www.digitalapplied.com/blog/claude-code-mods-function-hooks-explained covers what else they can do, and our comparison of hooks across 12 coding agents https://www.digitalapplied.com/blog/ai-coding-agent-hooks-compared-tool-interception 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 https://code.claude.com/docs/en/plugins/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 https://code.claude.com/docs/en/plugins/mods/test 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 : js 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 https://www.digitalapplied.com/blog/claude-code-mod-security-review-checklist 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 https://www.digitalapplied.com/services/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.