Follow Discord
Sweep 06 Nov 2026 · 01:32Z Build v2.1.319 537 read Stable v2.1.311 Latest v2.1.318 Next v2.1.319 Feeds RSS JSON llms.txt llms-full.txt Unofficial
Mods · 10 of 10

Recipes

Nine short patterns you can paste and adapt, each one run at 2.1.286.

Read against 2.1.286 Anthropic's page: Create a mod 19,509 characters 10 sections recipes.json, paged

Nine patterns, each small enough to paste and adapt. Most come from Anthropic's own mods, since those are the only production mods anyone can read, and the rest are built from behaviour I measured. I ran every one of them at 2.1.286, each as its own plugin loaded with --plugin-dir, and where the old version of a recipe was wrong, this page has the fixed one. Three needed fixing this time: the pane, the noun and the tool.check one. At 2.1.287 I re-ran the guard, the redactor and the telemetry refusal against Anthropic's events page, and the rest still stand on the 2.1.286 runs.

One rule covers every recipe that hooks session.start. A module registers each event once per matcher, so recipes 3 and 8 can't both go into one module as written. At 2.1.287 a module with two unmatched session.start hooks fails claude plugin validate and doesn't load:

modules../register.js: twice: /tmp/a94-mods-probe/twice/hooks/register.js:3: on("session.start") is registered twice without a matcher; the first is at /tmp/a94-mods-probe/twice/hooks/register.js:2; $ is always spelled $.noun.event(...) at the call site, on is always on("<event>", hook), and next.to always next.to(e, "<tier>")

The test kit refuses the same module with hooks module did not load: and that line.

Put everything your mod does at session start in one hook, as Anthropic's events page says.

1. A guard that fails closed

Hooks fail open by default, so anything security-related needs .catch.

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 matter here. The check runs before next, because denying after it means the command already ran. The .catch turns a throw or a ten-second overrun into a denial, where without it the guard would just vanish. And the reason is specific, because the model reads it.

Calling next(e) from the .catch once next.called is true doesn't run the command a second time. The 2.1.287 declarations say so ("next(e) resolves to what the hook's last call settled to, nothing beneath running again"), and in the test kit a guard that threw after next came back { runs: 1, result: { result: 'ran 1' } }, so the tool ran once and its result was kept. A guard that threw before next came back { runs: 0, result: { deny: 'guard failed: throw' } }. Anthropic's version of this has no next.called check, so going by its code it denies in both cases, and a command that already ran reaches Claude as refused. Neither shape runs the command twice.

If you're guarding a capability and not a single tool, widen the matcher: { tool: ["Bash", "Write", "Edit", "NotebookEdit"] }. One hook over the whole set is easier to reason about than four.

2. A redactor on the way back up

This one leaves the question alone and changes the answer. It can sit anywhere in the chain, since 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 answered = await next(e)

  if ("deny" in answered) return answered

  const seen = JSON.stringify(answered.result)
  const cleaned = seen.replace(SECRETS, "[redacted]")

  if (cleaned === seen) return answered

  return { result: JSON.parse(cleaned) }
})

Edit result, because that's what the model reads. What next(e) gives back for a Bash call had four keys at 2.1.287, ref,result,text,isReadOnly, and the earlier version of this recipe spread it and swapped out only text. That redacts nothing. With the shell printing fruit PINEAPPLE basket . and PINEAPPLE added to the pattern, Haiku's reply at 2.1.287 was still fruit PINEAPPLE basket ..

This page used to say the ref left on a spread object makes core use its own messages, so a spread could never work. That went too far. Anthropic's events page says to await next(e) and return a copy of the result with a field replaced, and at 2.1.287 { ...answered, result: cleaned } redacted, ref and all. The reply was fruit [redacted] basket .. The fresh { result } above gave the same reply in the same setup. Core checks the result against the tool's output schema and builds what the model reads from it, so either shape works as long as the field you replace is result.

It has one real limit. You only see what tools return, so a secret that never passes through a tool result never passes through you.

3. Keeping the engine for the rest of the session

This is how Anthropic's diff mod uses $ outside a hook. You can't store $ itself, so you store arrow functions that call through it.

import type { On, SessionUsage, Timer } from "claude-code"

type Host = {
  now: () => Promise<number>
  every: (ms: number, fn: () => void) => Timer
  read: (path: string) => Promise<string>
  usage: () => Promise<SessionUsage>
  status: (text: string | undefined) => void
  invalidate: () => 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),
    usage:      () => $.session.usage(),
    status:   text => $.ui.status(text),
    invalidate: () => $.ui.invalidate("ui.render"),
  }

  return next(e)
})

Each call is still written $.noun.verb(...) inside a hook body, so the capability scan sees everything your mod uses. What gets out is a table of plain functions. The rest of your mod depends on Host and never touches the engine, so you can test it without one.

$.clock.now() is a dispatch like everything else, so it returns a promise. status and invalidate return nothing, and every hands back a Timer you can cancel().

Start every other hook with if (!host) return next(e), so a dispatch that arrives before session.start passes through and doesn't throw.

This is the only session.start hook your module gets, as the top of the page says. Recipe 8 builds the host and registers a command in one hook, and that's the shape to copy when you need both.

4. Watching tool calls without getting in the way

The finally shape, straight from diff. It can't change the result and it can't 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 helper for the result type. !("deny" in result) tells a finished call from a refused one. The bookkeeping sits in finally, so it runs even when the chain below rejects. And it's void ... .catch(() => undefined) because a pass-through hook whose own logging rejects has failed, and a failed hook is a skipped hook.

5. A status line that keeps itself current

Since 2.1.283 you don't need a timer for this. session.measure fires after each turn and when a plan limit's percent used changes, going by Anthropic's reference, and it hands you the same numbers $.session.usage() would:

on("session.measure", async ($, e, next) => {
  const percent = e.context.percent

  if (percent !== undefined) $.ui.status(percent > 80 ? `context ${percent}%` : undefined)

  return next(e)
})

e.changed says which of context, rateLimits and cost moved, if you only care about one. The first measure lists everything it has a number for. At 2.1.286 mine came in during the first turn, before turn.complete, with all three listed and the context already at 10%. That's earlier than "after each turn" reads, and I haven't re-run it at 2.1.287 to see which is right now.

The engine puts your plugin's name in front of the line, so don't add it yourself. With the threshold dropped to 5 so it would show, mine read ⚠ r5b-status: context 15% at 2.1.286. In a headless -p session there's no status row, and the call only leaves a line in the debug log.

The undefined check still matters. context.percent is a missing key until a response reports one, and percent > 80 on a missing key is undefined > 80, which is false, so the warning would never fire while everything looked fine.

$.ui.status(undefined) clears the line, which is why the ternary has no empty string in it.

On a build older than 2.1.283, poll: a $.clock.every(60_000, ...) from session.start plus a refresh after each turn.complete, both calling $.session.usage() through a Host like the one in recipe 3.

6. Adding a noun other mods can use

Built-in mods add nouns from engine.create, and the engine page shows how telemetry does it. At 2.1.286 an installed plugin can do it too, as long as every call through beneath, the engine coming back up the fold, is spelled out in full:

import type { On } from "claude-code"

type AuditEntry = { what: string }

const AUDIT_LOG = "/tmp/audit.log"

export function register(on: On) {
  on("engine.create", async ($, e, next) => {
    const beneath = await next(e)

    const audit = {
      record: async (entry: AuditEntry) => {
        const before = await beneath.fs.read(AUDIT_LOG).catch(() => "")

        await beneath.fs.write(AUDIT_LOG, before + entry.what + "\n")
      },
    }

    return { ...beneath, audit }
  })
}

$.fs has no append at 2.1.286, so record reads the log and writes it back with the new line on the end. A consumer that recorded twice left both lines in the file. It isn't safe against two calls landing at once though, since both can read the same old file and the second write drops the first entry.

Another plugin then calls $.audit.record({ what: "..." }) like any other noun, and the debug log shows the hop between my two test plugins: $.audit.record (r6c-consumer -> r6b-noun) answered in 10.8ms. The plugin that adds the noun can call it from its own hooks as well, which I also ran at 2.1.286.

beneath gets the same treatment as $. Write beneath.noun.verb(...) at the call site and don't hand it to a helper. The older version of this section did exactly that, passing beneath to a small write function, and at 2.1.286 both claude plugin validate and the loader still refuse it:

the value of next(e) at engine.create is passed as an argument

That was the shape this page used when it said an installed plugin couldn't add a noun. I only have 2.1.286 to test on, so I can't say whether the direct shape above already loaded at 2.1.283.

You can add a noun but you can't swap one core already has. Returning { ...beneath, store: { ... } } loads nothing, and the debug log says engine.create replaced $.store, added by core; to change what a method does, hook the method.

For types, ship a types/index.d.ts that adds your noun to EngineInterface inside declare module "claude-code", and name it in plugin.json as "types": "./types/index.d.ts". Validate then reports declares on $: $.audit. At 2.1.286 my consumer plugin still needed a // @ts-expect-error on the call. Anthropic's types section answers the open question. Name the provider under dependencies in the consumer's plugin.json, and loading the consumer writes the provider's additions into their own folder under .claude-plugin/types/, which should make the @ts-expect-error unnecessary. My probes had no dependencies, so I haven't seen that folder written.

telemetry itself is off limits. At 2.1.286 an installed plugin that calls $.telemetry.log or $.telemetry.mark gets refused, and validate passes the call without a warning. From a probe plugin named modsa-probe the error read HooksError: modsa-probe: $.telemetry.log: $.telemetry serves the plugins built into Claude Code alone. Anthropic's reference says "A record is sent only when Claude Code or a built-in mod raised it", which reads like a quiet drop. It isn't one. At 2.1.287 both calls still reject, and a session.start hook that caught them logged:

A94 telemetry.log rejected: HooksError: a94-spread: $.telemetry.log: $.telemetry serves the plugins built into Claude Code alone
A94 telemetry.mark rejected: HooksError: a94-spread: $.telemetry.mark: $.telemetry serves the plugins built into Claude Code alone

Leave the call out of an installed plugin. Uncaught, the rejection makes your hook throw, and a hook that throws is skipped.

7. A pane

const PANE = "my-mod-pane"

on("command.run", { command: "mypane" }, async ($, e, next) => {
  const opened = await $.ui.open({ id: PANE, title: "My pane", closeOnEscape: true, focus: true })

  isOpen = opened.isPlaced

  return {}             // print nothing in the transcript
})

on("ui.render", { component: "Pane" }, async ($, e, next) => {
  if (e.requestId !== PANE) return next(e)

  const { Box, Text } = $.ui.resolve(e)

  const width = Math.max(1, e.props.bodyColumns - 2)

  return <Box flexDirection="column">
    {rows.map(r =>
      <Text key={r.id}>{r.label.slice(0, width)}</Text>)}
  </Box>
})

/mypane has to exist before the person can type it, so register it from session.start the way recipe 8 does. The hook returns {} because a command that only opens a pane has nothing to print, as Anthropic's command section puts it. The old version returned { text: "" }, and I didn't check what that one prints.

Draw the whole list and let the engine window it. The old version sliced rows to e.props.scroll.bodyRows, and at 2.1.286 that gives you an empty frame on an inline pane. bodyRows counts the rows the window shows as drawn now, so the first render reads 0, returns nothing, and it stays 0. Once my probe returned all 40 rows, the next render read bodyRows: 13 and the pane showed rows 0 to 12.

return next(e) on a pane that isn't yours is what you owe every other mod that draws. Elements come from $.ui.resolve(e), which is synchronous, and never from an import, because each surface has its own set. A resolved element the surface lacks draws an empty fragment, and a node you build by hand gets the whole tree refused, both covered on the UI page.

A pane the person asked for, like this one opened from their command, is always placed. Mine came back {"isPlaced":true} on a 100 column terminal at 2.1.286. One your mod opens by itself may not be on a narrow terminal, and isPlaced tells you which happened. Anthropic's limits table puts the line at 144 columns, or 110 once the person has opened that pane before. I haven't tested either number.

Redraw with $.ui.invalidate("ui.render"), and not often. diff batches its redraws through a timer and skips its git poll entirely while the pane is closed, which is the standard to aim for.

8. A command, and coping when it's refused

$.command.register can fail, most often because a built-in already has the name, and diff treats that as normal:

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))) {
      engine.uiLog(`my-mod: could not register /mypane`)
    }

    return next(e)        // host stays null, so every other hook does nothing
  }

  return next(e)
})

Leaving host null is the whole fallback plan. Every other hook starts with if (!host) return next(e), so a mod that couldn't claim its command quietly does nothing, and doesn't throw on every dispatch.

Staying quiet about the expected failure is on purpose too. A built-in holding the name isn't news, and anything else is.

9. A hook that only wants a say

If you don't need to own the tool call, don't hook tool.call. tool.check is where the permission decision gets made, its core doesn't run anything, and unlike a tool.call hook on Bash it doesn't break worktree isolation for subagents. I checked that at 2.1.286 with one subagent spawned with isolation: "worktree" and told to run pwd. With recipe 1's tool.call guard loaded it reported that the worktree isolation context had been lost, and with only this hook loaded it printed its worktree path.

on("tool.check", { tool: "Bash" }, async ($, e, next) => {
  const verdict = await next(e)
  const command = commandOf(e.input)

  if (command === undefined || !looksRisky(command)) return verdict

  return { ...verdict, decision: "ask", reason: "this command touches a path the mod watches" }
})

function commandOf(input: unknown) {
  if (typeof input !== "object" || input === null || !("command" in input)) return undefined

  return typeof input.command === "string" ? input.command : undefined
}

The tool's arguments are under e.input, and it's typed unknown, so narrow it before you read command. The old version read e.input.command straight, which doesn't type-check at 2.1.283 or 2.1.286. The answer is { decision, reason?, rule? }.

When a plugin asks, the prompt names the hook and the plugins the check went through, and in my run that list had the built-in cc-plugin-sec-default in it next to mine. At 2.1.286, with my test plugin called r9-check, it read:

Hook tool.check requires confirmation for this command:
this command touches a path the mod watches [plugin:cc-plugin-sec-default+r9-check]

The same idea goes for tool.describe, which changes what the model thinks a tool does without touching what it does.

Where to read real ones

2.1.286 registers eleven built-in mods, going by the list in its bundle, and four of them loaded a hooks module in my sessions, as the overview says. They live in Claude Code's own mods/ directory. At 2.1.286 they go by cc-plugin- names, so diff shows up in the debug log as cc-plugin-diff. diff is the one to read. Its hooks/register.ts covers most of what a real mod does, and every capability the /diff pane uses is one your mod has too.

Some mods people have published, listed because they exist and you can read them. I don't vouch for any of them: cc-arcade, cc-storytime (which runs a small transformer inside the mod and spends no API tokens), claude-mods, Mindful-Claude, opencues, drawer, PromptSign and pragma. I haven't checked which of them still load at 2.1.286.

Feedback