How to Mod Claude Code: The Seven Mods I Built in Four Days
Claude Code 2.1.287 added mods: TypeScript handlers that run inside Claude Code and can rewrite tool calls and draw their own panes. I built seven with Claude in four days. Here's what they do, what broke, and how to write your first one in 25 lines.
Claude Code 2.1.287 added mods: TypeScript handlers that run inside Claude Code and can rewrite tool calls and draw their own panes. I built seven of them with Claude between 1 and 4 October. This is what they do, what broke along the way, and how to write your first one in 25 lines.
I didn't plan to build any of this. I read the launch post, asked Claude what a mod actually was, and by the end of that evening six of them were loading in every session I opened. The seventh, a panel for watching subagents, started from a screenshot I saw on X of pixel Claude avatars wearing role hats. It took a late Saturday night and most of Sunday.
I should be upfront about the split. I'm a product manager, not a TypeScript developer. I described what I wanted, looked at what came back, and said what was wrong with it. Claude wrote the code, using Anthropic's plugin-authoring skill. Every mod below went through that loop several times, and the interesting part is mostly in what I kept sending back.
Why I stopped writing shell hooks for this
I have a rule that nothing I write contains an em dash. For months a shell hook enforced it. Claude would write a file, the hook would see the dash and block the write, and Claude would try again. It worked, but every flagged file cost a full retry, and I'd watch the same paragraph get written twice.
What I wanted was simpler: take the file, swap the dashes for commas, and let the write go through. A settings hook couldn't do that cleanly. It runs as a separate script, outside Claude Code, and talks back through a JSON reply. Anthropic's mods overview puts the difference plainly: settings hooks, skills and MCP servers "work from outside Claude Code," while a mod "runs inside Claude Code."
Running inside means a mod can do things the outside tools can't. It can change a tool call before it runs, draw a pane beside the transcript or a band above the prompt, and put its own line in the status bar. And its handlers share variables, so one can count something and another can display it.
What a mod looks like
A mod is a plugin with a TypeScript file in it. Mine each have three files:
.claude-plugin/plugin.json: the name and a one-line descriptionhooks/hooks.json: points Claude Code at the codehooks/register.tsx: the handlers
The hooks.json is one line:
{ "modules": ["./register.tsx"] }
Inside register.tsx you export a register function. Claude Code hands it an on function, and you call on with an event name and a handler. Each handler gets three things: $, which is Claude Code itself (the UI, the session, the clock), e, the event, and next, which passes the event along. Call next(e) and nothing changes. Call next with an edited event and Claude Code uses your version instead.
The 25-line example: em-dash-fixer
Here's the mod that replaced my blocking hook, unedited:
import type { Register } from 'claude-code'
const DASH = '\u2014'
// Files that talk about the character on purpose (the guard hooks, this mod).
const SKIP = /em-dash|block-em-dashes|sloptrim/i
const fix = (text: string) =>
text
.replace(new RegExp(`\\s*${DASH}\\s*`, 'g'), ', ')
.replace(/, ([.,;:!?)])/g, '$1')
export const register: Register = on => {
on('tool.call', { tool: 'Write' }, ($, e, next) => {
if (SKIP.test(e.file_path) || !e.content.includes(DASH)) return next(e)
$.ui.toast(`em-dash-fixer: rewrote dashes in ${e.file_path.split('/').at(-1)}`)
return next({ ...e, content: fix(e.content) })
})
// Only new_string changes: old_string must still match the file as it is.
on('tool.call', { tool: 'Edit' }, ($, e, next) => {
if (SKIP.test(e.file_path) || !e.new_string.includes(DASH)) return next(e)
$.ui.toast(`em-dash-fixer: rewrote dashes in ${e.file_path.split('/').at(-1)}`)
return next({ ...e, new_string: fix(e.new_string) })
})
}
Two handlers, one for Write and one for Edit. Each checks whether the content has a dash, and if not, passes the call through untouched. If it does, it shows a toast saying which file it fixed and passes on a copy with the dashes replaced. The SKIP line keeps it away from the files that need to contain a literal dash, like the hook that used to block them.
The retry is gone. Claude writes once, the file lands clean, and a small toast tells me it happened.
The other six
Once the first one worked, the rest came quickly. These all load in every session:
- agent-panel (626 lines): a side pane with one row per subagent, showing its cost, tokens read, context size, steps and time, with a small pixel avatar by role. It's the biggest by far, and the one I look at most. When a subagent starts reading far more than the job needs, I see it while it's happening.
- topic-drift-band (64 lines): watches the words in my prompts. From the third prompt on, if a new one shares less than 15% of its vocabulary with the session so far, a band appears above the prompt asking "New topic?" with a button that copies a starter prompt for a fresh chat. Long mixed-topic sessions were my biggest token cost, and this catches me drifting.
- context-band (62 lines): my auto-compact fires at 260K tokens, so this mod treats that as full and puts a band with a Compact button above the prompt at 80% of it (0.8 x 260K = 208K). I'd rather compact on purpose at a task boundary than have it happen mid-edit.
- active-week-pane (57 lines): a pane listing the threads I marked active this week in my memory file. Click one and it fills the prompt with the question I left myself for picking that thread back up.
- secret-scrubber (40 lines): masks API keys and tokens in tool output before the model or the transcript sees them. It runs the tool first, then walks the result and replaces anything shaped like an Anthropic, OpenAI, GitHub, Slack, AWS or Telegram key.
- usage-pace (25 lines): a status line that reads my weekly usage against how much of the week has passed.
That last one fixes a mistake I kept making. The usage meter says 33%, and 33% sounds fine. But if four days of the seven have gone, 33% is a slow week, and if one day has gone, it's a fast one. The mod shows both numbers side by side:
import type { EngineInterface, Register } from 'claude-code'
const WEEK = 7 * 24 * 3600 * 1000
const refresh = async ($: EngineInterface) => {
const { rateLimits } = await $.session.usage()
const week = rateLimits.find(r => r.kind.includes('seven_day'))
if (!week?.resetsAt) return $.ui.status(undefined)
const now = await $.clock.now()
const left = Math.max(0, new Date(week.resetsAt).getTime() - now)
const elapsed = Math.min(1, Math.max(0.01, 1 - left / WEEK))
const pace = week.percentUsed / (elapsed * 100)
const verdict = pace > 1.15 ? 'Over pace' : pace < 0.85 ? 'Under pace' : 'On pace'
// Show the arithmetic: share used against share of the window elapsed.
const used = Math.round(week.percentUsed)
const filled = Math.round((used / 100) * 5)
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(5 - filled)
// Same bar as the CLI status line; the engine prefixes the plugin name, "Usage".
$.ui.status(`${bar} ${used}% / ${Math.round(elapsed * 100)}% week | ${verdict}`)
}
export const register: Register = on => {
on('session.start', async ($, e, next) => { await refresh($); return next(e) })
on('turn.complete', async ($, e, next) => { await refresh($); return next(e) })
}
It reads the seven-day limit from $.session.usage(), works out what share of the window has passed, and divides one by the other. Above 1.15 it says "Over pace," below 0.85 "Under pace." It refreshes when a session starts and after every turn.
Line count across all seven: 626 + 64 + 62 + 57 + 40 + 25 + 25 = 899.
What went wrong along the way
Most of the time went into small things I only noticed once a mod was running in front of me.
- The status line got renamed twice. Claude Code prints the mod's name in front of its status line, and there's no way to hide it. The first version was called
7d, so my status bar read "7d 28% / 36% week," which meant nothing to anyone but me. Renaming the plugin tousage-paceand its label to Usage fixed it. I also cut a "2.6d of 7" counter, because it said the same thing as the week percentage. - A percentage was the wrong trigger. The context band first appeared at 60% full. But the window is about 1M tokens, my auto-compact fires at 260K, and a separate guard of mine blocks a turn at 350K. 60% of 1M is 600K, so the band would have shown up after both of those had already fired. It now triggers on a token count instead.
- Yellow text disappeared. The first bands used yellow to stand out. On the desktop app's light theme, I couldn't read them. Bold text in the default color, with one orange word (Claude's
#D97757) on the drift band, reads in both themes. - The agent panel flickered. Every time the pane redrew, every animated avatar restarted from frame one, so a panel with four agents running looked like it was strobing. Now the avatars only move while their agent is working, and the panel refreshes every five seconds instead of on every event.
- The scrubber masked things twice. A GitHub token got replaced with
[redacted:github], and then the general "anything called TOKEN" pattern matched the replacement and masked it again. It's cosmetic, and it's still on my list.
None of these were hard to fix. Each one needed someone looking at the real thing on a real screen, which is the part I could do and Claude couldn't.
How to write your first one
- Update Claude Code to 2.1.287 or later. That's the release where the changelog says "Added Claude Mods."
- Make a folder with the three files above. Start from em-dash-fixer if you want something that works on the first try.
- Load it. I list my mod folders in the
CLAUDE_CODE_PLUGIN_DIRSenvironment variable insettings.json, separated by colons, so they load in every session. To try one for a single session, useclaude --plugin-dir ./your-mod. - Run
claude plugin validateon the folder before you load it. It catches a broken manifest before Claude Code does.
You don't have to write the code yourself, either. The docs suggest describing the mod you want in a session and letting Claude write it, which is how all of mine started. Anthropic also publishes sample mods, including blast-radius, which holds a risky shell command like rm -rf and shows what it would change before it runs.
One thing to take seriously
A mod is your code running with your permissions. Anthropic's docs are direct about it: mods "aren't sandboxed," and a mod can read your environment variables and every prompt you send. Turning on sandboxing doesn't cover a process a mod starts. Read a mod before you install it, the same as you would a shell script from a stranger. All seven of mine are short enough to read in a few minutes, except agent-panel, and I wrote that one.
Full reference: Mods overview in the Claude Code docs. For the older, settings-file kind of hook, see hooks in this guide.
Related posts
Stating a Rule Twice Does Not Make Claude Follow It
I counted rule violations across 745 of my own Claude Code sessions. Repetition changed nothing. Conflict between rules explained almost everything. Here is the script so you can count your own.
966 Memory Files: What It Took to Keep Claude Code Reading the Right One
Anthropic's docs explain CLAUDE.md and auto memory well, and they stop at about the point where the folder gets big. This is what broke in mine after that point, with the numbers.
Why Most People Use Claude Code Wrong
You installed Claude Code, typed a prompt, got a mid answer, and walked away. Here's what you missed.
New guides, when they ship
One email, roughly weekly. CLAUDE.md templates, workflows I actually use, and the cut-for-length stuff that does not make the public guides. One-click unsubscribe.
Or read Product Field Notes, the Substack


