Hook Chain

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:

2. Guards that fail closed

Two panels of the same garden gate. Left: the robot sentry has dozed off and the gate swings open, letting a card float through. Right: a second robot with a lantern has shut the gate and fastened a big padlock, and the card waits outside.
Left: a guard that throws, answers the wrong shape or runs past its budget is skipped, and the call goes ahead. Right: the same guard with .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:

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:

WhereLost onUse it for
Module variableEvery reload (a save, /reload-plugins, an option change)Caches and scratch values
$.state/clear, /resume, /branch, and the end of the sessionAnything a drawing reads. A write redraws exactly the readers.
$.storeNothing, until deleted or unused for cleanupPeriodDaysAnything 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:

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:

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

  1. Return next(e) unless you mean to answer, and judge before you call it.
  2. Every guard gets .catch. Every secrets guard hooks prompt.mention too.
  3. $.store is the source of truth. Reload it in classic.SessionStart.
  4. Keep $ visible to the loader: no destructuring, no aliases.
  5. 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.