
·
How I used Claude Code mods to keep my repository's ubiquitous language visible while I work: a Glossary Browser for Matt Pocock's domain-modeling skill, with a Term Check that steers both me and the model toward the canonical terms.
Claude Code mods just came out, and the moment I read the getting-started post I wanted to build one. The problem with a brand new extension point is always the same: you need a real use case, not a toy. So I started looking through the codebase of the website you're reading right now, searching for something that could benefit from living inside my Claude Code session.
I found it in the glossary. Recently I wrote down the ubiquitous language of this repository: every term, what it means, and which words to avoid. The model is not the problem: the skills I use load the glossary, so the agents read it every time they need it. The problem is me. The terms are there, carefully written, but nothing keeps them in view while I work, and I have to remember to open the file. Spoiler: most of the time, I don't.
In this post I will show you the mod I built to fix this. It has two parts, one for each of us. The Glossary Browser is for me: a band above the prompt and a dedicated pane that keep the terms in view and let me look them up in a second. The Term Check is for the model: a note attached to my prompts and a refusal on its Markdown edits, both steering it toward the canonical terms. Let's start.
The glossary of this repository is not something I invented from scratch. It follows the format of
Matt Pocock's domain-modeling skill.
The skill is usually driven by
grill-with-docs, which runs a
grilling session on a plan and
records the terms and the decisions as they settle. The result is a small set of Markdown files, described in the
GLOSSARY-FORMAT.md
doc of the skill:
GLOSSARY.md per context (in the Domain-Driven Design sense of bounded context), placed in the folder that context ownsGLOSSARY-MAP.md at the repository root, linking every context's glossary and describing the relationships
between them (or, for a small project, a single root GLOSSARY.md)GLOSSARY.md, a ## Language section with one **Term**: entry per term, optionally grouped by ###
headings, each with its definition and an _Avoid_: list of the words not to use for itdocs/adr/, at the root for the system-wide ones and inside each
context's folder for the othersThis is, for example, the entry for a Post in the glossary of the website context (apps/website/GLOSSARY.md):
**Post**:
One dated entry in the Blog, technical or personal.
_Avoid_: Article, blog post
My website has four contexts (Website, Matrix Design System, Matrix Rain, and Agentic Delivery), for a total of 67 terms and 12 ADRs. It's a pretty good investment: when I say "Post" or "Topic" or "Work Unit", I know exactly what I mean, and so does every agent that reads the glossary.
There's a catch though. The agents get the glossary through the skills, but I don't have a skill loading it into my head. A file sitting at the root of the repository is documentation, and documentation is the first thing I forget when I'm focused on the code. This is exactly the kind of thing a mod can solve.
So, what is a Claude Code mod? A mod is a small JavaScript or TypeScript module that runs inside your Claude Code session. Instead of being called from the outside, it lives in the session and sees every event: prompts, tool calls, turns, slash commands, and even the rendering of the UI. The getting-started post describes mods as "a way to fit Claude Code to how you work", and I think that's the best summary you can get.
These are the things that make mods really interesting:
next(e) hands
the event to the next plugin, and at the bottom Claude Code does what it would have done anyway. This means a hook
can observe an event (call next and look at the result), rewrite it (change it before calling next), or answer
it directly (return without calling next at all)./command, and respond to it.$.If you have used hooks in the Claude Code settings, the difference is substantial. A settings hook is a shell command executed for each event, exchanging JSON over stdin and stdout: it starts, does its job, and dies. A mod is loaded once, and it has UI, state, and middleware. It's the difference between a script that reacts to Claude Code and a piece of Claude Code that you write yourself.
This is the real point of mods: they let you adapt Claude Code to your own needs. Not the needs of the average user, yours. And my need was to keep my glossary in view.
The first part of the mod is the Glossary Browser. When the session starts, it reads the glossary and draws a band above the prompt with the contexts, the number of terms, and the number of ADRs.
Pressing g on the band (or running /glossary) opens the pane. You can also jump straight to a term with
/glossary [term]. On the left, the pane lists the contexts, each one expandable to its terms (grouped by section)
and its ADRs, plus the system-wide ADRs and the Relationships described in the map. A filter at the top searches every
term, definition, and ADR. On the right, the pane shows the selection. For a term, you get its definition, its Avoid
entry, the words the Term Check flags for it (more on this in a moment), and where the same word means something in
another context.
For an ADR, the pane renders the whole decision as Markdown. This is, for example, the system-wide ADR about the monorepo I described in my previous post.
Let's see how the band is drawn. A mod draws UI by hooking ui.render for a specific component. For the band, the
component is AbovePrompt. The hook reads the glossary from the mod state, resolves the UI elements for the current
surface with $.ui.resolve(e), and returns JSX:
on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
const glossary = await read($, glossaryAtom);
if (e.props.hasSurvey || glossary === null) {
return next(e);
}
const { Box, Button, Text } = $.ui.resolve(e);
const flags = await read($, flagsAtom);
const terms = glossary.contexts.reduce((sum, context) => sum + context.terms.length, 0);
const adrs = allAdrs(glossary).length;
// ...
return (
<Box flexDirection="column">
<Box gap={1}>
<Button key="open" label="Glossary" hotkey="g" onPress={() => openPane($)} />
<Text dimColor wrap="truncate">
{glossary.contexts.map((context) => context.name).join(" · ")} — {terms} terms · {adrs} ADRs
</Text>
</Box>
{/* the Term Check flags */}
</Box>
);
});
As you can see, when there's no glossary (or Claude Code is showing a survey in that spot) the hook simply calls
next(e) and gets out of the way. The glossary itself is read with $.fs, the sandboxed filesystem API. The mod first
looks for a GLOSSARY-MAP.md, and falls back to a single root GLOSSARY.md:
const loadGlossary = async ($: Engine): Promise<Glossary | null> => {
const root = await $.session.root();
if (await $.fs.exists(`${root}/GLOSSARY-MAP.md`)) {
const map = parseContextMap(await $.fs.read(`${root}/GLOSSARY-MAP.md`));
const contexts = await Promise.all(
map.entries.map(async (entry) => {
const path = `${root}/${glossaryFile(entry.dir)}`;
const text = (await $.fs.exists(path)) ? await $.fs.read(path) : "";
return parseContext(text, entry, await listAdrs($, root, entry.dir));
}),
);
return { contexts, systemAdrs: await listAdrs($, root, ""), relationships: map.relationships };
}
if (await $.fs.exists(`${root}/GLOSSARY.md`)) {
const text = await $.fs.read(`${root}/GLOSSARY.md`);
const context = parseContext(text, singleContextEntry(text), await listAdrs($, root, ""));
return { contexts: [context], systemAdrs: [], relationships: "" };
}
return null;
};
The glossary is read live, and read again whenever a GLOSSARY.md, the map, or an ADR is edited during the session.
One important design choice: the mod only reads the glossary, it never writes it. Writing the glossary is the job of
the domain-modeling skill. The mod just makes it visible.
Seeing the glossary is nice, but the real value comes from the second part of the mod: the Term Check. It looks for Avoid words in two places: my prompts, and the model's edits to Markdown files.
The first version of the check that comes to mind is a rewrite: you type "article", the mod replaces it with "Post".
I discarded it immediately. My prompt is my text, and a tool that silently changes what I wrote is a tool I can't
trust anymore. Instead, the Term Check leaves the prompt exactly as typed, and attaches a note to it that only the
model reads. The prompt.submit hook does this through the context of the prompt event:
on("prompt.submit", async ($, e, next) => {
const glossary = await read($, glossaryAtom);
if (glossary === null || e.text.trimStart().startsWith("/")) {
return next(e);
}
const found = check(e.text, glossary.contexts, "prompt");
await update($, flagsAtom, () => found);
return found.length === 0 ? next(e) : next({ ...e, context: [...(e.context ?? []), promptNote(found)] });
});
This is the middleware chain in action: the hook rewrites the event (adding context, not touching the text) and hands
it to next. When I typed just "article" in the prompt, this is the note the model received:
Glossary Term Check: the prompt uses Avoid words from this repository's glossary (GLOSSARY.md).
- 'Article' is an Avoid word in Website: when it means Post, say Post.
Use the canonical terms in your reply and your work. If a word is meant in another sense, ignore its line.
At the same time, the band shows the flag, so I see it too. Pressing 1 opens the flagged term in the pane, and x
clears the flags.
As you can see in the screenshot, the model got it right away: it answered that it read "article" as wanting to work on a Post. Pretty cool, isn't it? 😎
Notice the last line of the note: "If a word is meant in another sense, ignore its line". A word like "task" is an Avoid word in my Agentic Delivery context (the canonical term is Work Unit), but sometimes a task is just a task. The model is perfectly able to tell the difference, as long as you tell it that it's allowed to.
The second place where the Term Check works is the model's edits to Markdown files (.md and .mdx). Here the
mod is stricter. When the model runs a Write or an Edit on a Markdown file inside a context's folder, and the new
text uses that context's Avoid words, the edit is refused, with the canonical terms in the reason:
on("tool.call", async ($, e, next) => {
if (e.tool !== "Edit" && e.tool !== "Write") {
return next(e);
}
// ... resolve the file, the contexts owning it, and the new text
const found = glossary === null || contexts === null ? [] : check(text, contexts, relative, glossary.contexts);
if (found.length > 0) {
await addFlags($, found);
}
const retry = `${relative}\n${text}`;
const isRefused =
found.length > 0 &&
denyEdits &&
glossary !== null &&
isOwnedPath(relative, glossary) &&
!retried.has(retry);
if (isRefused) {
retried.add(retry);
return { deny: denyReason(found, relative) };
}
// ...
return next(e);
});
This is the third kind of hook from the getting-started post: the hook answers the event (a deny) without calling
next, so the tool never runs. The model reads the reason and rewrites the passage with the canonical term.
Look at the retried set though. If the model sends the same edit again, unchanged, it goes through. This is the way
out, and it's essential. Sometimes the Avoid word is meant: it's in a quote, it's in an example, or it's used in a
completely different sense. A check that can only say "no" would either block legitimate work or teach the model to
contort its prose around a regex. The refusal reason tells the model exactly this: "If a word is meant (a quote, code,
another sense), send the same edit again unchanged and it will pass". Refuse once, explain why, and trust the model
with the second attempt.
A check like this lives or dies by its false positives. If it flags too much, you stop reading the flags. So the Term
Check ignores several things, and all of this logic lives in hooks/glossary.ts, as pure functions:
withoutCanonical masks
every canonical term and context name from the text, with spaces or hyphens between the words.avoidWordsOf function splits the Avoid entry, drops parentheticals, cuts at clauses like "which" or "when", skips
entries that start with "using" or "calling", and keeps only short, word-like entries.This is the heart of check:
export const check = (text: string, contexts: GlossaryContext[], where: string, all = contexts): Flag[] => {
const prose = withoutCanonical(proseOnly(text), canonicalNames(all));
const flags: Flag[] = [];
const seen = new Set<string>();
for (const context of contexts) {
for (const term of context.terms) {
for (const word of term.avoidWords) {
const pattern = new RegExp(`\\b${escape(word).replace(/\s+/g, "\\s+")}(s|es)?\\b`, "i");
const key = `${context.name}|${word.toLowerCase()}`;
if (!seen.has(key) && pattern.test(prose)) {
seen.add(key);
flags.push({ word, term: term.name, context: context.name, where });
}
}
}
}
return flags;
};
My favorite moment of the whole project came while Claude was writing the README of the mod. The README lives in the repository, inside the Agentic Delivery context, so the Term Check checked it. And it refused three passages.
The first two were "by design" and "CI". Both are Avoid words in that context ("design" for an Approved Plan, "CI" for the Full Checks of my SDLC pipeline), but in the README "by design" was just an idiom, and "CI" meant the GitHub workflow, not the Full Checks. In both cases the model sent the same edit again, and the retry let it through. Exactly what the way out is for.
The third one was different: "design-system pieces". That was a real false positive. "Design System" is a canonical
name, so it should have been masked, but at that time withoutCanonical matched canonical names only when written
with a space, not with a hyphen. The mod found a bug in itself, and the bug became a fix with a test (you will see the
test in the next section). I don't think I could have asked for a better first user.
Now, let's see how a mod is actually built. A mod is shipped as a Claude Code plugin, and this is the layout of the Glossary Browser:
glossary-browser/
├── .claude-plugin/
│ └── plugin.json the manifest, with the user configuration
├── hooks/
│ ├── hooks.json { "modules": ["./register.tsx"] }
│ ├── register.tsx the mod: exports register(on, options)
│ ├── glossary.ts the pure logic: parsing, check, notes
│ └── glossary.test.ts the tests
└── types/
└── index.d.ts the state contract
hooks/hooks.json is what turns a plugin into a mod: instead of listing shell commands, it lists modules.
{ "modules": ["./register.tsx"] }
The module exports a register function, which receives on (to subscribe hooks to events) and the options of the
user configuration. Every hook you saw in the previous sections is registered inside it:
export const register: Register = (on, options) => {
const denyEdits = options.denyEdits !== false;
const retried = new Set<string>();
on("session.start", async ($, e, next) => {
await $.command.register({
name: "glossary",
description: "Open the Glossary Browser, optionally on a term: /glossary [term]",
});
await refresh($);
return next(e);
});
on("command.run", { command: "glossary" }, async ($, e) => {
// open the pane, on the term when one is given
});
// prompt.submit, tool.call, ui.render ...
};
Remember the hot reload? A module variable dies every time you save the file. Anything that must survive goes in the host state, declared as an atom. This is the atom of the Term Check flags:
const flagsAtom = atom({ plugin: "glossary-browser", key: "flags" } as const, []);
Each atom needs its entry in the PluginState interface, declared in types/index.d.ts, so that read and update
are fully typed:
declare module "claude-code" {
interface PluginState {
"glossary-browser": {
glossary: Glossary | null;
selected: string | null;
expanded: string | null;
filter: string;
page: number;
flags: Flag[];
};
}
}
Then you use the read/update pair from claude-code:
const flags = await read($, flagsAtom);
await update($, flagsAtom, () => []);
There's a gotcha here: plugin and key must be literals (hence the as const). If they are not,
claude plugin validate refuses the atom.
The refusal of Markdown edits can be turned off. The option is declared in the userConfig block of plugin.json:
"userConfig": {
"denyEdits": {
"type": "boolean",
"title": "Refuse Avoid words in edits",
"description": "Refuse a Write or Edit to a Markdown file that uses an Avoid word of the context owning that file, so the model rewrites it with the canonical term. Sending the same edit again unchanged lets it through. Off, the Term Check only flags the word in the band.",
"default": true
}
}
and it arrives in register as options.denyEdits. The mod reads it as options.denyEdits !== false, so that a
missing value behaves like the default.
Mods come with their own testing kit, claude-code/testing. This is the test that verifies the check flags Avoid words
in prose, naming the context and the canonical term:
test("flags Avoid words in prose, naming the context and the canonical term", async () => {
const flags = check("the implementer finished the task, now the articles", glossaryOf().contexts, "prompt");
expect(flags).toEqual([
{ word: "Article", term: "Post", context: "Website", where: "prompt" },
{ word: "task", term: "Work Unit", context: "Agentic Delivery", where: "prompt" },
]);
});
And this is the test born from the false positive of the README:
test("ignores a canonical name written with hyphens", async () => {
const designSystem = parseContext(
"# Design System\n\n## Language\n\n**Design System**:\nThe component library.\n",
{ name: "Design System", dir: "packages/design-system", summary: "the library" },
[],
);
const contexts = [...glossaryOf().contexts, designSystem];
expect(check("the design-system pieces", contexts, "prompt")).toEqual([]);
expect(check("the design of the pieces", contexts, "prompt").map((flag) => flag.word)).toEqual(["design"]);
});
The testing kit has no filesystem. This is why all the parsing and checking logic lives in glossary.ts as pure
functions that take strings and return values, while register.tsx is just the thin layer that talks to $. It's
the same separation I use for the components of this website, and it pays off in the same way.
The loop is pleasantly short:
claude plugin validate <dir> to check the manifest, the hooks, and the atomsclaude plugin test <dir> to run the teststsc, once a session has loaded the mod: the engine writes the type declarations for claude-code
at load time, and your PluginState entry extends themOne last gotcha: not every surface has the same elements. The mobile surface has no Input, so the filter of the pane
is optional, and the pane checks for it before using it:
const elements = $.ui.resolve(e);
const { Box, Button, Markdown, Text } = elements;
const Input = "Input" in elements ? elements.Input : null;
The full source of the mod is on GitHub, in the
claude-plugins/glossary-browser
folder of the repository of this website.
Building this mod taught me a few things about mods in general:
CONTEXT.md and CONTEXT-MAP.md to GLOSSARY.md
and GLOSSARY-MAP.md on 2026-08-15. My repository still used the old names, and the mod almost shipped reading them.
A mod built on top of someone else's format must track that format, not your local snapshot of it.The Glossary Browser is published in my plugin marketplace. You can install it in Claude Code with:
/plugin marketplace add chicio/chicio-labs
/plugin install glossary-browser@chicio-labs
/reload-plugins
It works in any repository with a GLOSSARY.md or a GLOSSARY-MAP.md at the root, and without a glossary it stays
quiet. It pairs naturally with domain-modeling and grill-with-docs: grill a plan, and the terms the session
resolves show up in the band and the pane, and the Term Check starts steering toward them. All the details are in
the README. The
current release is 0.2.0.
In a previous post I described how we encoded our own harness in a set of skills and subagents: the idea that the tools around the model are where your way of working lives. Mods push that idea one step further. Skills tell the model what to do. Mods change the environment where the model and I work together: what I see, what the model reads, and what it's allowed to write.
The Glossary Browser is a small mod, but it changed how I use my glossary. It's no longer a file I'm supposed to remember. It's right there, above the prompt, whenever I need to look a term up, while the Term Check gently keeps the model on the language we agreed on. And this is, I think, the real promise of mods: not a feature someone else decided you need, but Claude Code shaped around your way of working. 🚀
How I implemented a three-layer guardrail system for the AI chatbot on my portfolio website, using regex-based prompt injection detection, Llama Prompt Guard for injection classification, and an LLM-as-judge for topic relevance.
A personal essay looking back at my first 18 years as a software engineer, and the life, and the losses, that ran alongside them.