Nine patterns, each one small enough to paste and adapt. Most are lifted from Anthropic's own three mods, because those are the only production mods anybody can read. The rest are built from behaviour measured at 2.1.272 and say so.
1. A guard that fails closed
The default is fail-open, so this needs .catch and it is not optional for anything security-shaped.
import type { On } from "claude-code"
const FORBIDDEN = [/rm\s+-rf\s+\//, /:\(\)\{.*\};:/, /\bdd\s+if=/]
export function register(on: On) {
on("tool.call", { tool: "Bash" }, async ($, e, next) => {
if (FORBIDDEN.some(re => re.test(e.command))) {
return { deny: "refused by the local safety mod" }
}
return next(e)
})
.catch(($, e, next) =>
next.called ? next(e) : { deny: `guard failed: ${next.error.kind}` })
}
Three things are load-bearing. The check runs before next, because returning deny after it means the command already ran. The .catch turns a throw or a ten-second overrun into a denial rather than an absence. And the reason is specific, because the model reads it.
If you are guarding a capability rather than a tool, widen the matcher instead of adding hooks: { tool: ["Bash", "Write", "Edit", "NotebookEdit"] }. One hook per event per plugin is enforced, so this is the only way.
2. A redactor on the way back up
Refining the answer rather than the question. Sit wherever you like; the result passes through you on the way out.
const SECRETS = /\b(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{36})\b/g
on("tool.call", { tool: ["Bash", "Read"] }, async ($, e, next) => {
const r = await next(e)
if (typeof r.text !== "string") return r
return { ...r, text: r.text.replace(SECRETS, "[redacted]") }
})
Spread the result rather than building a new object. You do not know what other keys the event carries at your build, and dropping one silently changes behaviour beneath you.
This one has an honest limitation: you see what the tool returned, so a secret that never passes through a tool result never passes through you.
3. Capturing the engine for the rest of the session
The pattern from Anthropic's diff mod, and the answer to "how do I use $ outside a hook". You cannot store $, so store arrows that call through it.
type Host = {
now: () => number
every: (ms: number, fn: () => void) => Timer
read: (path: string) => Promise<string>
status: (text: string | undefined) => Promise<void>
invalidate: () => Promise<void>
}
let host: Host | null = null
on("session.start", async ($, e, next) => {
host = {
now: () => $.clock.now(),
every: (ms, fn) => $.clock.every(ms, fn),
read: path => $.fs.read(path),
status: text => $.ui.status(text),
invalidate: () => $.ui.invalidate("ui.render"),
}
return next(e)
})
Every call site is still literally $.noun.verb(...) inside a hook body, so the capability scanner sees the full inventory. What escapes is a table of ordinary functions. The rest of your mod depends on Host and never imports the engine at all, which also makes it testable without one.
Guard every other hook with if (!host) return next(e), so a dispatch arriving before session.start passes through instead of throwing.
4. Observing tool calls without interfering
The finally shape, straight from diff. It cannot change the outcome and it cannot fail the dispatch.
on("tool.call", { tool: [...EDITING_TOOLS] }, async ($, e, next) => {
let result: ResultOf["tool.call"] | undefined
try {
result = await next(e)
return result
} finally {
const didRun = result !== undefined && !("deny" in result)
if (didRun && host) void recordEdit(host, e).catch(() => undefined)
}
})
Four details worth copying. ResultOf["tool.call"] is the public result-type helper. !("deny" in result) is how you tell a completed call from a refused one. The bookkeeping is in finally, so it runs even when the chain beneath rejects. And it is void … .catch(() => undefined), because a passthrough hook whose own logging rejects has just failed, and a failed hook is a skipped hook.
5. A status line that keeps itself current
Usage is the common case and it has a trap in it, so this is the version that works.
let timer: Timer | null = null
on("session.start", async ($, e, next) => {
host = makeHost($)
timer = $.clock.every(60_000, () => void refresh().catch(() => undefined))
return next(e)
})
on("turn.complete", async ($, e, next) => {
const r = await next(e)
void refresh().catch(() => undefined)
return r
})
async function refresh() {
if (!host) return
const usage = await host.usage()
const percent = usage.context.percent
if (percent === undefined) return // no reading yet, not a low reading
await host.status(percent > 80 ? `context ${percent}%` : undefined)
}
usage.context.percent is an absent key until a turn completes, so the explicit undefined check is the whole recipe. Without it, percent > 80 is undefined > 80, which is false, and the warning never fires while everything looks healthy.
The timer plus turn.complete pair is what every usage mod in the wild does, because there is no event for crossing a threshold. poteat has said as much: today a quota guard has to poll.
host.status(undefined) clears the line, which is why the ternary has no empty string in it.
6. Adding a noun other mods can use
on("engine.create", async ($, e, next) => {
const beneath = await next(e)
const audit = {
record: (entry: AuditEntry) => writeRow(beneath, entry),
}
return { ...beneath, audit }
})
Close over beneath and never over your own $. Spread it rather than constructing a fresh object, since anything you drop is a capability somebody above you added.
Ship a declaration file with no imports in it, pointed at by "types" in plugin.json:
declare module "claude-code" {
interface EngineInterface {
audit: { record: (entry: AuditEntry) => Promise<void> }
}
}
Two constraints you will hit. Replacing an existing noun is refused, so extend rather than override and hook the method when you want to change what it does. And you cannot consume your own noun, anywhere in your plugin, because the re-entrancy guard is per plugin. You will end up with the implementation inlined once for yourself and once behind the noun. That is genuinely unpleasant and there is no way around it at 2.1.272.
7. A pane
const PANE = "my-mod-pane"
on("command.run", { command: "mypane" }, async ($, e, next) => {
await $.ui.open({ id: PANE, title: "My pane", closeOnEscape: true, focus: true })
isOpen = true
return { text: "" }
})
on("ui.render", { component: "Pane" }, async ($, e, next) => {
if (e.requestId !== PANE) return next(e)
const { Box, Text } = await $.ui.resolve(e)
const width = Math.max(1, e.props.bodyColumns - 2)
return <Box flexDirection="column">
{rows.slice(0, e.props.scroll.bodyRows).map(r =>
<Text key={r.id}>{r.label.slice(0, width)}</Text>)}
</Box>
})
The return next(e) on a pane that is not yours is the contract with every other mod that draws. Elements come from $.ui.resolve(e) and never from an import, because the set differs per surface and an element the surface lacks draws a silent fragment.
Redraw by calling $.ui.invalidate("ui.render"), sparingly. diff coalesces its redraws through a timer and skips its git poll entirely while the pane is closed, which is the standard to aim at.
8. A command, and surviving being refused
$.command.register can fail, most often because a built-in already holds the name, and diff treats that as a normal outcome rather than an error:
on("session.start", async ($, e, next) => {
const engine = makeHost($)
try {
await engine.registerCommand({ name: "mypane", description: "Open my pane" })
host = engine
} catch (error) {
if (!BUILTIN_HOLDS.test(messageOf(error))) {
await engine.uiLog(`my-mod: could not register /mypane`)
}
return next(e) // host stays null; every other hook no-ops
}
return next(e)
})
Leaving host null is the entire degradation strategy. Every other hook opens with if (!host) return next(e), so a mod that could not claim its command becomes a mod that does nothing, rather than a mod that throws on every dispatch.
The silence on the expected failure is deliberate too. A built-in holding the name is not news; anything else is.
9. A hook that only wants a say
If you do not need to own the tool call, do not hook tool.call. tool.check is where the permission decision is made, its core has no side effect, and it does not break worktree isolation for subagents the way a tool.call hook on Bash does.
on("tool.check", { tool: "Bash" }, async ($, e, next) => {
const verdict = await next(e)
return looksRisky(e.command) ? { ...verdict, ask: true } : verdict
})
Same reasoning applies to tool.describe, which is how you change what the model believes a tool does without touching what it does.
Where to read real ones
Anthropic's three mods live in Claude Code's own mods/ directory: diff at 775 files, sec-default at 60, telemetry at 81. diff's hooks/register.ts is 900-odd lines across eleven distinct events and is the best single file to read, because every capability the /diff pane uses is one your mod has too.
In the wild, and none of these are endorsements, just mods that exist and can be read: cc-arcade, cc-storytime (which runs a 260K-parameter transformer inside the mod at roughly 2,600 tokens a second and spends no API tokens at all), claude-mods, Mindful-Claude, opencues, drawer, PromptSign and pragma.