Level 2 Writing mods well
Mods 201
Writing a mod that works on your machine is easy. Writing one that works in other people's sessions takes more. It has to stay correct next to other mods, keep its state across a /clear, refuse what it should even when it fails, and pass a test with no session around it. This page covers those habits.
1. Chain etiquette
Your hook is one link in a chain that also holds your organization's mods, other mods you installed, Claude Code's built-ins and the engine itself. Be a good neighbour:
- Return
next(e)unless you mean to answer. A hook that forgets to return it answers withundefined. Depending on the event, that is either a wrong-shape answer, which gets the hook skipped, or a silent takeover. - Spread, don't rebuild. Rewrite with
next({ ...e, command })so fields you didn't think about still reach the engine. Some fields are pinned, such as a subagent's identity or a stored row's metadata. Changing those gets the hook skipped. - Narrow with a matcher.
on('tool.call', { tool: 'Bash' }, …)runs only for Bash. That is cheaper and clearer than anifat the top of a hook that sees every call. - Judge before you call
next. Oncenexthas run, the tool has run. Anything you decide afterwards can change the result, not the act. - Stop when the dispatch is abandoned.
next.signalaborts when the user interrupts, another hook settles first or your budget runs out. Pass it to anything long you start.
2. Guards that fail closed
.catch.Every hook has a budget of 10 seconds of its own time. Time spent waiting inside next or a $ call doesn't count. $.clock.sleep does, and so does waiting on your own promise, such as a setTimeout. A hook that throws, returns the wrong shape or runs past its budget is skipped, and the next link runs in its place. For a counter that is the right default. For a guard it means the dangerous thing happens. The fix is one line:
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (DESTRUCTIVE.test(e.command)) return { deny: 'Ask the person to run this themselves.' }
return next(e)
}).catch(($, e, next) => next.called ? next(e) : { deny: 'guard failed; refusing to be safe' })
The handler must itself answer in the event's own shape. We found that a wrong-shape answer is handed to .catch as kind: 'throw', and if the handler's answer is also the wrong shape, the hook is skipped and the guard fails open anyway. Test the failure path, not just the happy one. The .catch handler gets a 1-second budget. If the hook failed before calling next, the handler refuses. If it failed after, next(e) replays what that call already settled to, so nothing runs twice. Since 2.1.290, claude plugin validate prints each hook at a gating site as gating hook with .catch or gating hook without .catch. It calls this a fact, not a warning, because whether a hook should fail open is yours to decide.
Four more things every guard author should know:
- There's a second door. A file named after
@in a prompt is read without anytool.call. That read raisesprompt.mention. A guard that keeps Claude away from.envhas to hook both. - Allow-list real paths; don't deny-list spellings.
$.fs.stat(path, { resolve: true })returnsrealPathwith symlinks and..resolved. A guard that checksrealPathagainst a root it resolved the same way is robust. A regex over spellings is best effort. - Auto mode reviews the input it saw. A hook that changes a call's input after auto mode's classifier approved it gets the call denied ("a hook changed this call's input…"). Rewrite before the classifier, or deny.
- Know your build. Before 2.1.295, a hook could be handed a deeply nested tool input that had been cut short with no error, so a guard could pass content it never saw. Before 2.1.296, pressing Esc during a mod's
prompt.submithook could let the unchecked prompt through. Guards need a current build.
3. State that survives
A mod has three places to keep things, and they last different lengths of time. What lasts how long covers them in full. The short version:
| Where | Lost on | Use it for |
|---|---|---|
| Module variable | Every reload (a save, /reload-plugins, an option change) | Caches and scratch values |
$.state | /clear, /resume, /branch, and the end of the session | Anything a drawing reads. A write redraws exactly the readers. |
$.store | Nothing, until deleted or unused for cleanupPeriodDays | Anything that must outlive the session |
The trap: /clear, /resume and /branch reset every $.state value to its default, and session.start doesn't fire again. A mod that mirrors $.store into $.state must reload in a classic.SessionStart hook. Otherwise its next write saves the default over the stored value. Our ledger sample had this bug until the docs pointed it out:
on('classic.SessionStart', async ($, e, next) => {
if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
const total = ((await $.store.get('all-time')) as Tally | undefined) ?? ZERO
await update($, allTime, () => total)
}
return next(e)
})
$.state values must be declared in a PluginState interface in types/index.d.ts, which plugin.json names under "types". References use literal plugin and key: atom({ plugin: 'ledger', key: 'session' } as const, ZERO). Write with update($, ref, fn), which retries on a version miss, so two button presses before a redraw both land. $.store is a JSON file per plugin, shared by every session on the machine, and get and set are not atomic across them. Use per-item keys and re-read before you write.
4. Drawing
A ui.render hook gets one component instance: e.component, e.surface (terminal or desktop today), e.props and e.viewport. It has the same three moves as any hook:
- Change props:
next({ ...e, props: { ...e.props, suffix } })keeps Claude Code's drawing with your change. - Replace: return your own tree built from
$.ui.resolve(e), the element table for that surface (Box, Text, Button, Link, Code, Markdown, Input, Select, Client, and Svg, Raster or Image depending on the surface). - Wrap:
await next(e)returns the engine's drawing as an element you can put inside your own Box. On the band above the prompt, returning a tree without embeddingnext(e)hides whatever later mods drew.
A tree that doesn't validate, such as an element the surface lacks or a prop it doesn't take, isn't drawn. Claude Code draws its own instead and logs why. Render hooks can't write state, so write from a button's handler or another event. For light touches you don't need a render hook at all: $.ui.status sets the line under the prompt, $.ui.toast shows a 4-second note, $.ui.log adds a dim transcript line Claude doesn't read, and $.ui.notify (2.1.295) raises a native notification. A pane Claude Code opens without the user asking appears only when the terminal is at least 144 columns wide (110 once the user has opened it before).
5. Commands and tools
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'tally', description: 'Show this session’s tool count.' })
return next(e)
})
on('command.run', { command: 'tally' }, async () => ({ text: `Tool calls so far: ${count}` }))
Register in session.start. Claude Code waits for it before the first prompt, so the command exists from turn one. The text you return shows as the command's output, and Claude reads it. With immediate: true a command runs even while Claude is mid-turn. Built-in names are refused.
$.tool.register gives Claude a new tool, listed as mcp__<plugin>__<name>. You serve it from your own tool.call hook with that name as the matcher. By default it sits behind tool search like an MCP tool. Pass isDeferred: false (2.1.293) to list its schema up front.
6. Calling a model
$.model.complete({ model, prompt }) runs one completion with no history on the user's own plan or key. It always resolves and never throws over what the provider did, so check isAnswered. When it is false, reason says why: api-error, empty-reply or aborted. Since 2.1.292, prompt and system take blocks, and cache: true on a block caches the request up to it. $.model.fork({ prompt }) asks one tool-less question over the current transcript, served from the prompt cache. Both spend the user's usage, and validate lists them under calls:.
7. Work that outlives a hook
A hook belongs to one dispatch. Work that should keep going starts in session.start and runs on $.clock.every or $.clock.after. Those timers run until you cancel them or the module reloads. A timer callback that throws is logged and runs again next interval. $.prompt.submit queues a prompt that starts its own turn once the session is idle, so background work can wake a quiet session.
8. Write what the loader can read
Claude Code refuses to load a module whose use of $ it can't follow. Static analysis is how it lists a mod's calls, so these all fail validate:
- Destructuring
$(const { ui } = $) or aliasing a namespace (const ui = $.ui). - Passing
$to anything but a top-level function declared in the same file. - Computed event names, dynamic
import(), and bare imports other thanclaude-code(types plus theatom,read,update,deriveandmemberOfhelpers). - Plugin names that look like Anthropic's. In our tests that meant the prefixes
claude-,anthropic-,anthropics-andcc-plugin-, plus exact names such asclaude-code.
9. Test without a session
claude plugin test runs your *.test.ts files against the real engine with no session, network or sign-in. It exits 1 on failure, so it fits CI. The idea that takes getting used to: the hooks your test registers sit beneath your mod and stand in for Claude Code. Nothing else is there, so the test has to answer every event and $ call the mod passes down.
import { expect, test } from 'claude-code/testing'
test('holds force pushes, passes the rest trimmed', async ($, on) => {
const ran: string[] = []
// This hook plays the engine: it records what reached it and answers.
on('tool.call', (_$, e) => {
if (e.tool === 'Bash') ran.push(e.command)
return { result: { stdout: '', stderr: '', interrupted: false } } as never
})
const held = await $.tool.call({ tool: 'Bash', command: 'git push --force origin main' })
expect(JSON.stringify(held)).toContain('destructive')
await $.tool.call({ tool: 'Bash', command: ' ls -la ' })
expect(ran).toEqual(['ls -la'])
})
The kit also provides mock.clock, mock.store, mock.session (2.1.293) and $.ui.mount({ plugin, surface, component, props }) with press, input and find for drawings. Loop UI tests over ['terminal', 'desktop'] so they don't depend on one surface. Each test has a 5-second limit, and the kit refuses two hooks on the same event from one test.
What to remember
- Return
next(e)unless you mean to answer, and judge before you call it. - Every guard gets
.catch. Every secrets guard hooksprompt.mentiontoo. $.storeis the source of truth. Reload it inclassic.SessionStart.- Keep
$visible to the loader: no destructuring, no aliases. - In tests your hooks are the engine, so answer everything.
Next: Mods 301 covers tiers, policy mods, failure modes, admin controls and distribution. Or see what we observed in Field tests.