Skip to main content
AI & Full-Stack·14 min read

Claude Code Mods in TypeScript: Production Guardrails for Next.js Teams (2026)

Anthropic shipped Claude Code mods on October 1, 2026. Build a TypeScript plugin that blocks force-pushes, redacts secrets from tool output, and asks before destructive shell commands, without replacing human review.

By Mussawar Hayat

Claude Code Mods Are the New Control Plane

On October 1, 2026 Anthropic shipped mods for Claude Code: small TypeScript or JavaScript functions that sit on the events the agent already emits. A mod can rewrite a prompt, deny a tool call, redact a secret from tool output, or draw extra UI. They ship inside plugins and run in the CLI and the desktop app, starting with Claude Code v2.1.287.

That is the practical answer to the conversation dominating developer timelines this week. Coding agents are no longer a novelty. Teams are arguing about how to keep them from pushing to main, leaking .env values back into the model, or running rm -rf because a prompt looked plausible. Hooks could observe. Mods can rewrite and answer. This guide builds one production-shaped mod for a Next.js and TypeScript repo, using only the shapes documented by Anthropic.

What You Will Learn

  • How a mod differs from settings hooks, skills, and MCP servers
  • The plugin layout Claude Code loads with no bundler
  • A TypeScript module that denies force-pushes, asks before destructive Bash, and redacts secrets from tool results
  • How matchers, next, and tool.check fit together
  • Security limits: mods are not sandboxed, and managed settings still win
  • How to load, hot-reload, and share the plugin without weakening review

Table of Contents

  1. The problem mods actually solve
  2. How a mod works
  3. Plugin layout
  4. Step-by-step implementation
  5. Best practices for Next.js teams
  6. Security considerations
  7. Performance notes
  8. Common mistakes
  9. Real use cases
  10. FAQ
  11. Summary

1. The Problem

Claude Code already has permission rules, auto mode, and settings hooks. Those layers still leave a gap teams hit every day on a Next.js codebase:

  • A Bash allow rule cannot easily say “git push is fine, except on main, except force.”
  • A settings hook can block a call. It cannot rewrite the tool result so a leaked DATABASE_URL never re-enters the model context.
  • Skills describe how the agent should behave. They do not enforce that behavior when the model ignores the skill.
  • MCP servers add tools. They do not intercept Edit, Write, or Bash.

Mods close that gap with a middleware chain. Official docs describe three moves: observe (call next(e) after your work), rewrite (call next with a changed copy), or answer (return a result and never call next). Events are frozen. You copy them; you do not mutate them.

2. How a Mod Works

A mod is a plugin whose hooks/hooks.json lists a module under modules. Claude Code calls the exported register(on, options) function once on load and again on hot reload. Inside register, on(event, matcher?, hook) subscribes to an event.

Every hook receives three arguments:

  • $ — the mods API ($.ui, $.command, $.process, $.state, and others)
  • e — the event payload
  • next — the rest of the chain, ending in Claude Code itself

The events this guide uses are documented on the mods events page:

  • tool.call fires before a tool runs, including subagent and MCP tools. e.tool is the name. Bash exposes e.command. Edit and Write expose e.file_path. Returning { deny: 'reason' } skips the tool. Returning { result: '...' } answers the call yourself.
  • tool.check fires after permission rules and settings hooks. next(e) resolves to allow, ask, or deny. A mod can return a different decision. Managed-settings blocks still win.
  • prompt.submit sees e.text before the turn. You can pass a trimmed copy or add private context.
  • ui.render with a matcher such as { component: 'Spinner' } can append text the user sees.

Matchers compare fields. A string matches one value, an array matches any value, a regular expression matches a pattern. Register each event and matcher pair once. A second bare on('session.start') fails to load.

Mods are not sandboxed. Anthropic’s launch note is explicit: a mod has the same access to the machine as Claude Code. Install only code you have read. On Team and Enterprise plans a built-in mod named sec-default loads first and stops user mods from overriding permission deny rules. If you replace that load order, keep sec-default in the list.

3. Plugin Layout

Claude Code loads .js, .mjs, and .ts directly. You do not need Node, a bundler, or a build step for the mod itself. This layout matches the official create-a-mod tutorial:

next-guard/
  .claude-plugin/plugin.json
  hooks/hooks.json
  hooks/register.ts

Check the CLI before you start. Mods require Claude Code v2.1.287 or later:

claude --version

4. Step-by-Step Implementation

4.1 Manifest

Save this as next-guard/.claude-plugin/plugin.json. The manifest is an ordinary plugin manifest. The modules entry in hooks.json is what makes it a mod.

{
  "name": "next-guard",
  "version": "0.1.0",
  "description": "Blocks force-pushes, asks before destructive shell commands, and redacts secrets from tool output.",
  "author": { "name": "Your Team" }
}

4.2 Point Claude Code at the module

Save this as next-guard/hooks/hooks.json:

{
  "description": "Next.js repo guardrails",
  "modules": ["./register.ts"]
}

4.3 The hooks module

Save this as next-guard/hooks/register.ts. The patterns below are taken from the official events guide: matchers, { deny }, $.ui.ask, and post-tool result rewriting. The secret scan is local string replacement. It is a backstop, not a guarantee that a secret never existed on disk.

const FORCE_PUSH = /git push\b[\s\S]*--force|git push\b[\s\S]*\s-f\b/
const RISKY = /\brm\s+-rf?\b|\bgit\s+reset\s+--hard\b|\bprisma\s+migrate\s+reset\b|\bdropdb\b/

const SECRET = /(sk-[A-Za-z0-9_\-]{20,}|postgres(?:ql)?:\/\/\S+|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]+PRIVATE KEY-----)/g

function scrub(value: string): string {
  return value.replace(SECRET, '[redacted]')
}

export function register(on: Function) {
  on('tool.call', { tool: 'Bash' }, async ($: any, e: any, next: Function) => {
    const command = String(e.command ?? '')

    if (FORCE_PUSH.test(command)) {
      return {
        deny: 'Force pushes are blocked by next-guard. Push a new branch and open a pull request.'
      }
    }

    if (!RISKY.test(command)) return next(e)

    let answer = 'Refuse'
    try {
      answer = await $.ui.ask('Run this command? ' + command, ['Run it', 'Refuse'])
    } catch {
      // Dismissed dialog, or a headless claude -p run with nobody to ask.
    }

    if (answer !== 'Run it') {
      return {
        deny: 'The user declined this command. Propose a narrower command or ask first.'
      }
    }

    return next(e)
  })

  on('tool.call', { tool: ['Edit', 'Write'] }, async ($: any, e: any, next: Function) => {
    const filePath = String(e.file_path ?? '')
    if (/(^|\/)\.env(\..*)?$/.test(filePath) || filePath.includes('secrets/')) {
      return {
        deny: 'next-guard blocks writes to env and secrets paths. Edit those files yourself.'
      }
    }
    return next(e)
  })

  on('tool.call', async (_$: any, e: any, next: Function) => {
    const result = await next(e)
    if (!result || typeof result !== 'object') return result
    const text = typeof result.result === 'string' ? result.result : ''
    if (!text || !SECRET.test(text)) return result
    SECRET.lastIndex = 0
    return { ...result, result: scrub(text) }
  })

  on('tool.check', { tool: 'Bash' }, async ($: any, e: any, next: Function) => {
    const decided = await next(e)
    const command = String(e.input?.command ?? '')
    if (!command.includes('git push')) return decided
    const branch = await $.process.run(['git', 'branch', '--show-current'])
    if (String(branch.stdout ?? '').trim() !== 'main') return decided
    return { decision: 'deny', reason: 'Push from a branch other than main.' }
  })
}

What each hook does:

  • The Bash tool.call hook answers force-pushes itself, so the command never runs and no permission prompt appears. For rm -rf, git reset --hard, prisma migrate reset, and dropdb, it waits on $.ui.ask. Official docs say time spent inside a mods API call does not count against the hook time limit. A custom promise does. Default the answer to refuse so a headless claude -p run cannot click through.
  • The Edit/Write hook refuses paths that look like env files. Do this in the mod and also keep those files out of the worktree the agent can see.
  • The unfiltered tool.call hook runs after next and rewrites string results that match a small secret pattern. Later mods see the event first if they loaded earlier; stack order is load order. Put the redaction mod early if you want it to see raw output, and remember that a deny from an earlier hook never reaches this one.
  • The tool.check hook refuses git push while the current branch is main. This is a reminder for the agent. Protect main on the Git host as well. A text match misses git -C ../other push and shell wrappers.

4.4 Load it

Start a session with the plugin directory. Claude Code watches that directory and hot-reloads when the module changes. Module-level variables reset on reload; use plugin state if you need a counter to survive a save.

claude --plugin-dir ./next-guard

Ask the agent to run git push --force on a throwaway branch. The command should not run, and the transcript should show the deny text. Ask it to print a fake sk- token from a fixture file and confirm the model’s next turn sees [redacted]. Then open /plugin, select Installed, and confirm you can disable the mod.

4.5 Share it

A mod Claude writes into ~/.claude/dev-mods// loads only in that session, and Claude Code deletes the folder after cleanupPeriodDays. Copy the directory into the repo, for example tools/next-guard, and document the launch flag. To publish it, package the plugin and submit it to the Claude plugin directory. Teammates should review the module in the same pull request as any other executable.

5. Best Practices

  • Keep the mod small and boring. One plugin for guardrails, another for UI. A 2,000-line mod is as hard to review as the agent output you are trying to control.
  • Write deny text as an instruction the model can follow. “Blocked” is worse than “Push a new branch and open a pull request.”
  • Prefer matchers over a giant if on every tool. A Bash matcher does not run for Read.
  • Do not duplicate policy that a permission rule already expresses. Bash(npm test) needs no code. Use tool.check when the decision depends on live state, such as the current branch.
  • Pair the mod with the project rules you already keep for Claude Code. The mod enforces a few sharp edges. CLAUDE.md still has to describe the App Router tree, the Prisma client, and the test command.
  • Work on a branch. A mod that blocks pushes to main is not a substitute for branch protection.
  • Version the plugin next to the app. When Next.js or Prisma conventions change, the path denylist should change in the same commit.

6. Security Considerations

  • Mods run with Claude Code’s privileges. A malicious mod can read the same files and call the same tools the agent can. Review source before --plugin-dir or a marketplace install.
  • Managed settings hooks run before a mod’s tool.call hook, and a block from them is final. Do not assume a user mod can weaken an org deny.
  • sec-default exists so user mods cannot override permission deny rules on Team and Enterprise. Leave it in the load list.
  • Redaction in the tool result does not delete the secret from disk, shell history, or a transcript line you already printed with $.ui.log. Keep production credentials off the agent machine.
  • Regex denylists miss obfuscation. git push --force is easy. bash -c with a constructed string is not. Treat the mod as one layer beside host-side sandboxing and GitHub branch protection.
  • Headless claude -p runs have nobody to answer $.ui.ask. The sample defaults to refuse. Do not default to allow.
  • Hot reload re-runs register. A bug that throws during register disables the guard until you fix it. Watch the transcript line that lists hooks after a reload.

7. Performance Notes

A hook runs on the events you subscribe to. An unfiltered tool.call hook runs for every tool, including Read. Keep that hook to a string scan and an early return. Do not shell out on the hot path.

$.process.run in the tool.check sample spawns git only when the command text contains git push. That is one process per push attempt, not per keystroke. Avoid network calls inside tool.call. Official docs warn that a hook which exceeds its time limit is skipped, and a skipped guard means the tool runs.

UI invalidation and spinner rewrites are cheap if you only change a suffix. Do not fetch status from CI on every spinner frame.

8. Common Mistakes

  • Mutating e. The event is frozen. Assigning a field throws. Copy it.
  • Registering the same event twice without a matcher. The module fails to load.
  • Calling next and also returning { deny }. If you called next, the tool already ran. Answer by returning without next.
  • Forgetting SECRET.lastIndex = 0 after a global regex test. The next call can miss a match.
  • Assuming the mod replaces auto mode, permission rules, or code review. It does not.
  • Shipping the plugin only in ~/.claude. Session mods are deleted. Commit the directory.
  • Trusting a marketplace listing you have not read. Same rule as an npm install on a laptop that holds production tokens.

9. Real Use Cases

  • App Router refactors. Allow Edit under app/ and components/, deny writes under prisma/migrations unless a human runs the migrate command.
  • Preview deploys. A tool.check hook can deny vercel --prod while the branch is not a release branch, and leave preview deploys to the existing permission rule.
  • Secret hygiene. Scrub tool output before the model plans the next edit, so a dumped .env.local does not get copied into a Server Action.
  • Review support. A spinner suffix that counts Bash calls makes a long auto-mode session visible without adding tokens to the prompt.
  • Team policy. Admins can load an org mod before user mods and record tool names. Anthropic’s launch post lists audit logging as an intended team use. Log names and paths, not raw command output.

FAQ

Do I need Claude Code 2.1.287?

Yes. Mods load on v2.1.287 or later, in the CLI and the desktop app. Check with claude --version. They are on by default; --safe-mode, --bare, and disableAllHooks stop them.

Is a mod the same as a settings hook?

No. Settings hooks can observe and block. Mods can also rewrite the event, replace the result, draw UI, and register commands. Managed settings hooks still run before a mod’s tool hook, and their block is final.

Can I write the mod in TypeScript?

Yes. Claude Code loads .ts and .tsx modules directly. The official tutorial uses JavaScript; the same register export works in TypeScript. You do not bundle it.

Will this stop every dangerous command?

No. Matchers and regexes miss wrapped shells, aliases, and tools you did not list. Use the mod with branch protection, a sandbox, and human review of the diff.

Where should the plugin live?

In the repo, loaded with claude --plugin-dir, or installed from a marketplace you trust. Session mods under ~/.claude/dev-mods are temporary.

Summary

Claude Code mods, released October 1, 2026, are the first supported way to rewrite agent events in TypeScript without waiting for a product feature. The useful production shape is small: deny force-pushes, ask before destructive shell commands, refuse env-file writes, and scrub secrets from tool results before the model reads them.

Keep the module in the repository, load it with --plugin-dir, and treat it as code. It does not replace permission rules, sec-default, branch protection, or review. It makes those layers harder to skip on an ordinary Next.js session.

Key Takeaway

Ship a reviewed TypeScript mod that answers the dangerous calls itself, and leave everything else to next.


Need a production agent workflow on Next.js?

I design and build React, Next.js, Node.js, and TypeScript systems, including the guardrails around coding agents. Get in touch or see the services page.

Related reading: Claude Code auto mode production guide and Postgres write guardrails for AI agents.

References

Frequently Asked Questions

Do I need Claude Code 2.1.287?

Yes. Mods load on v2.1.287 or later, in the CLI and the desktop app. Check with claude --version. They are on by default; --safe-mode, --bare, and disableAllHooks stop them.

Is a mod the same as a settings hook?

No. Settings hooks can observe and block. Mods can also rewrite the event, replace the result, draw UI, and register commands. Managed settings hooks still run before a mod tool hook, and their block is final.

Can I write the mod in TypeScript?

Yes. Claude Code loads .ts and .tsx modules directly. The official tutorial uses JavaScript; the same register export works in TypeScript. You do not bundle it.

Will this stop every dangerous command?

No. Matchers and regexes miss wrapped shells, aliases, and tools you did not list. Use the mod with branch protection, a sandbox, and human review of the diff.

Where should the plugin live?

In the repo, loaded with claude --plugin-dir, or installed from a marketplace you trust. Session mods under ~/.claude/dev-mods are temporary.