{"slug": "how-to-build-your-first-claude-code-mod-a-worked-example", "title": "How to Build Your First Claude Code Mod: A Worked Example", "summary": "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.", "body_md": "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.\n\n1. 01Three filesA manifest, a hooks.json that points to your code, and one TypeScript module that registers the hook.\n2. 02One hook does itA tool.call hook lets the tool run, then returns a redacted copy of the result.\n3. 03Test, then run liveclaude plugin test passed on our first version, but a real session still leaked the key.\n4. 04Not a sandboxA mod runs with your permissions. Review one before you install it.\n\n## 01 — ContextWhat we are building, and why a mod\n\nWhen 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.\n\nClaude 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.\n\n##### Settings hook\n\nA script that reads JSON and prints a decision. Works in any language; tested by running the script.\n\n##### Mod\n\nA function Claude Code calls in its own process, with a test kit and access to the interface.\n\n## 02 — Step 1The three files, plus a test\n\nA 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:\n\n```\nredact-secrets/\n├── .claude-plugin/\n│   └── plugin.json\n├── hooks/\n│   ├── hooks.json\n│   └── register.ts\n└── tests/\n    └── redact.test.ts\n```\n\nThe manifest, `.claude-plugin/plugin.json`, needs no fields specific to mods:\n\n```\n{\n  \"name\": \"redact-secrets\",\n  \"version\": \"0.1.0\",\n  \"description\": \"Replaces API keys and private keys in tool output before Claude reads them\",\n  \"author\": { \"name\": \"Your Name\" }\n}\n```\n\n`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:\n\n```\n{\n  \"description\": \"The redact-secrets hooks module\",\n  \"modules\": [\"./register.ts\"]\n}\n```\n\n## 03 — Step 2The hook: let the tool run, then clean the result\n\nSave 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.\n\n```\n// Patterns for common secret formats. Extend this list for your own keys.\nconst PATTERNS: RegExp[] = [\n  /sk-[A-Za-z0-9_-]{20,}/g, // OpenAI- and Anthropic-style API keys\n  /AKIA[0-9A-Z]{16}/g, // AWS access key IDs\n  /gh[pousr]_[A-Za-z0-9]{36,}/g, // GitHub tokens\n  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,\n]\n\n// Replace secrets in every string inside a value, counting replacements\nfunction redact(value: unknown, hits: { count: number }): unknown {\n  if (typeof value === 'string') {\n    let out = value\n    for (const pattern of PATTERNS) {\n      out = out.replace(pattern, () => {\n        hits.count += 1\n        return '[REDACTED]'\n      })\n    }\n    return out\n  }\n  if (Array.isArray(value)) return value.map((item) => redact(item, hits))\n  if (value && typeof value === 'object') {\n    return Object.fromEntries(\n      Object.entries(value).map(([key, item]) => [key, redact(item, hits)]),\n    )\n  }\n  return value\n}\n\nexport function register(on) {\n  // Runs around every Bash and Read call\n  on('tool.call', { tool: ['Bash', 'Read'] }, async ($, e, next) => {\n    // Let the tool run, then inspect what it returned\n    const result = await next(e)\n    // Leave refused calls alone\n    if (result.deny) return result\n    const hits = { count: 0 }\n    // Build a redacted copy rather than editing the result in place\n    const cleaned = redact(result, hits)\n    if (hits.count === 0) return result\n    // A dim line in the transcript that Claude doesn't read\n    $.ui.log('redact-secrets: hid ' + hits.count + ' secret(s) from ' + e.tool)\n    return cleaned\n  })\n}\n```\n\nThree 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.\n\nOur 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.\n\n```\n{\n  \"ref\": 1,\n  \"result\": {\n    \"stdout\": \"APP_NAME=demo\\nOPENAI_API_KEY=sk-fake…\\nDEBUG=true\",\n    \"stderr\": \"\",\n    \"interrupted\": false,\n    \"isImage\": false,\n    \"noOutputExpected\": false\n  },\n  \"text\": \"APP_NAME=demo\\nOPENAI_API_KEY=sk-fake…\\nDEBUG=true\",\n  \"isReadOnly\": true\n}\n```\n\n## 04 — Step 3Test it without starting a session\n\nClaude 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`:\n\n``` js\nimport { expect, test } from 'claude-code/testing'\n\ntest('an API key in Bash output is replaced', async ($, on) => {\n  // Stand in for Bash: the \"tool\" returns a line containing a key\n  on('tool.call', () => ({\n    result: { stdout: 'OPENAI_API_KEY=sk-test1234567890abcdefghijklmn' },\n    text: 'OPENAI_API_KEY=sk-test1234567890abcdefghijklmn',\n  }))\n  // Answer the mod's $.ui.log call\n  on('ui.log', () => ({ value: undefined }))\n  const result = await $.tool.call({ tool: 'Bash', command: 'cat .env' })\n  expect(result.text).toBe('OPENAI_API_KEY=[REDACTED]')\n  expect(result.result.stdout).toBe('OPENAI_API_KEY=[REDACTED]')\n})\n\ntest('output without secrets passes through unchanged', async ($, on) => {\n  on('tool.call', () => ({ result: { stdout: 'hello' }, text: 'hello' }))\n  const result = await $.tool.call({ tool: 'Bash', command: 'echo hello' })\n  expect(result.text).toBe('hello')\n})\n```\n\nThen 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.\n\n```\ncd redact-secrets\nclaude plugin validate .\nclaude plugin test\n❯ ./register.ts hooks: tool.call{tool=Bash|Read}\n  ❯ ./register.ts calls: $.ui.log\n✔ Validation passed\n\n 2 pass\n 0 fail\nRan 2 tests across 1 file.\n```\n\n## 05 — Step 4Run it in a real session\n\nMake 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:\n\n```\nclaude -p \"Use the Bash tool to run exactly: cat fake.env  Then reply with the tool output verbatim, nothing else.\" \\\n  --plugin-dir ./redact-secrets --allowedTools \"Bash(cat:*)\" --model haiku\n```\n\nClaude’s reply, with the mod loaded:\n\n```\nAPP_NAME=demo\nOPENAI_API_KEY=[REDACTED]\nDEBUG=true\n```\n\nWe 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.\n\n## 06 — Practical implicationsEnable it, share it, and know its limits\n\nKnow 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.\n\n### Add your own key formats and run it live\n\nCopy 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.", "url": "https://wpnews.pro/news/how-to-build-your-first-claude-code-mod-a-worked-example", "canonical_source": "https://www.digitalapplied.com/blog/build-first-claude-code-mod-tutorial", "published_at": "2026-10-03 00:00:00+00:00", "updated_at": "2026-10-03 11:38:09.862823+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents", "ai-safety"], "entities": ["Claude Code", "Anthropic", "Claude Code 2.1.287", "Claude Code 2.1.288"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-to-build-your-first-claude-code-mod-a-worked-example", "markdown": "https://wpnews.pro/news/how-to-build-your-first-claude-code-mod-a-worked-example.md", "text": "https://wpnews.pro/news/how-to-build-your-first-claude-code-mod-a-worked-example.txt", "jsonld": "https://wpnews.pro/news/how-to-build-your-first-claude-code-mod-a-worked-example.jsonld"}}