{"page":"recipes","title":"Recipes","blurb":"Nine short patterns you can paste and adapt, each one run at 2.1.286.","build":"2.1.286","sections":["1. A guard that fails closed","2. A redactor on the way back up","3. Keeping the engine for the rest of the session","4. Watching tool calls without getting in the way","5. A status line that keeps itself current","6. Adding a noun other mods can use","7. A pane","8. A command, and coping when it's refused","9. A hook that only wants a say","Where to read real ones"],"limit":8000,"section":null,"offset":0,"count":7714,"total":19509,"next_offset":7714,"next_url":"https:\/\/changelogs.core-directive.com\/reference\/mods\/recipes.json?offset=7714","markdown":"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](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/events), and the rest still stand on the 2.1.286 runs.\n\nOne 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:\n\n```text\nmodules..\/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>\")\n```\n\nThe test kit refuses the same module with `hooks module did not load:` and that line.\n\nPut everything your mod does at session start in one hook, as Anthropic's [events page](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/events#filter-which-events-a-hook-handles) says.\n\n## 1. A guard that fails closed\n\nHooks fail open by default, so anything security-related needs `.catch`.\n\n```ts\nimport type { On } from \"claude-code\"\n\nconst FORBIDDEN = [\/rm\\s+-rf\\s+\\\/\/, \/:\\(\\)\\{.*\\};:\/, \/\\bdd\\s+if=\/]\n\nexport function register(on: On) {\n  on(\"tool.call\", { tool: \"Bash\" }, async ($, e, next) => {\n    if (FORBIDDEN.some(re => re.test(e.command))) {\n      return { deny: \"refused by the local safety mod\" }\n    }\n\n    return next(e)\n  })\n  .catch(($, e, next) =>\n    next.called ? next(e) : { deny: `guard failed: ${next.error.kind}` })\n}\n```\n\nThree 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.\n\nCalling `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](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/events#handle-a-hook-that-fails) 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.\n\nIf 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.\n\n## 2. A redactor on the way back up\n\nThis 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.\n\n```ts\nconst SECRETS = \/\\b(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{36})\\b\/g\n\non(\"tool.call\", { tool: [\"Bash\", \"Read\"] }, async ($, e, next) => {\n  const answered = await next(e)\n\n  if (\"deny\" in answered) return answered\n\n  const seen = JSON.stringify(answered.result)\n  const cleaned = seen.replace(SECRETS, \"[redacted]\")\n\n  if (cleaned === seen) return answered\n\n  return { result: JSON.parse(cleaned) }\n})\n```\n\nEdit `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 .`.\n\nThis 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](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/events#rewrite-an-event) 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`.\n\nIt has one real limit. You only see what tools return, so a secret that never passes through a tool result never passes through you.\n\n## 3. Keeping the engine for the rest of the session\n\nThis is how Anthropic's `diff` mod uses `$` outside a hook. You can't store `$` itself, so you store arrow functions that call through it.\n\n```ts\nimport type { On, SessionUsage, Timer } from \"claude-code\"\n\ntype Host = {\n  now: () => Promise<number>\n  every: (ms: number, fn: () => void) => Timer\n  read: (path: string) => Promise<string>\n  usage: () => Promise<SessionUsage>\n  status: (text: string | undefined) => void\n  invalidate: () => void\n}\n\nlet host: Host | null = null\n\non(\"session.start\", async ($, e, next) => {\n  host = {\n    now:        () => $.clock.now(),\n    every: (ms, fn) => $.clock.every(ms, fn),\n    read:     path => $.fs.read(path),\n    usage:      () => $.session.usage(),\n    status:   text => $.ui.status(text),\n    invalidate: () => $.ui.invalidate(\"ui.render\"),\n  }\n\n  return next(e)\n})\n```\n\nEach 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.\n\n`$.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()`.\n\nStart every other hook with `if (!host) return next(e)`, so a dispatch that arrives before `session.start` passes through and doesn't throw.\n\nThis 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.\n\n## 4. Watching tool calls without getting in the way\n\nThe `finally` shape, straight from `diff`. It can't change the result and it can't fail the dispatch.\n\n```ts\non(\"tool.call\", { tool: [...EDITING_TOOLS] }, async ($, e, next) => {\n  let result: ResultOf[\"tool.call\"] | undefined\n\n  try {\n    result = await next(e)\n\n    return result\n  } finally {\n    const didRun = result !== undefined && !(\"deny\" in result)\n\n    if (didRun && host) void recordEdit(host, e).catch(() => undefined)\n  }\n})\n```\n\nFour 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.\n\n## 5. A status line that keeps itself current\n","previous_page":"gotchas","next_page":null,"url":"https:\/\/changelogs.core-directive.com\/reference\/mods\/recipes","index_url":"https:\/\/changelogs.core-directive.com\/reference\/mods.json"}