[DOCS] Mods: document that $ and state atoms cannot cross module imports (validate refuses the hooks module)

Status Open
Reported on v2.1.296
Maintainer reply None cached
Activity 0 comments · opened Oct 12, 2026

Documentation Type

Missing documentation (feature not documented)

Documentation Location

The plugin-authoring skill (SKILL.md, reference.md) and the mods type declarations (.claude-plugin/types/claude-code/index.d.ts).

Section/Topic

Splitting a hooks module into several files: passing $ to helpers, and sharing state atoms

Current Documentation

reference.md explains that a state reference needs literal plugin / key (atom(ref, initial)), and that a plugin's files are imported with import declarations. It does not say that $ cannot be passed across imports, or that the atom used by read / update must be declared in the file that calls them.

What's Wrong or Missing?

The static scan behind claude plugin validate (and the loader) only follows $ into functions declared at the top level of the same file, and only accepts read / update sources that are atom(...) consts declared in the same file. Violations make the whole hooks module fail to load. Passing on to an imported function works, which makes the asymmetry hard to guess.

_Error lines below are quoted from our plugin's validate output, with identifiers renamed to match each snippet and long tails elided (...)._

  1. $ passed to an imported helper:

``ts
// shared/notify.ts
export const toastOnce = async ($: EngineInterface, text: string) => { $.ui.toast(text) }
// register.ts
import { toastOnce } from './shared/notify'
on('session.start', async ($, e, next) => { await toastOnce($, 'hi'); return next(e) })
`
`
✘ ... $ is passed to "toastOnce", imported from "./shared/notify": $ is followed only into a function declared in this same file, never across an import; ...
``

  1. $ passed to a local function that is not top-level:

``ts
export const register: Register = on => {
const start = async ($: EngineInterface) => { await $.session.root() }
on('session.start', async ($, e, next) => { await start($); return next(e) })
}
`
`
✘ ... $ is passed to "start", which is not a function declared at the top of this file (a function declaration, or a const bound to one); ...
``

  1. An atom exported from another file:

``ts
// shared/state.ts
export const langAtom = atom({ plugin: 'my-mod', key: 'lang' } as const, 'ko')
// register.ts
import { langAtom } from './shared/state'
await update($, langAtom, () => 'en')
`
`
✘ ... the state library's update takes a source the scan can read: a reference whose plugin and key are string literals, or what atom, memberOf or derive (imported from "claude-code") made of one, written there or in a const of this file, ...
``

Consequences for a multi-file plugin: helpers that need $ (toast-once, output folder setup, store-backed settings, command registration) must be duplicated in every feature file, and shared state must be re-declared with identical literals in each file that reads it.

Suggested Improvement

  1. Document both rules (and that on may cross imports) in reference.md / the skill, with the recommended layout: pure helpers in shared files, $ calls and atoms in each feature file.
  2. If feasible, let the scan follow $ into imported top-level functions of the same plugin, and accept atoms exported from another file of the plugin.

Impact

Medium - Makes feature difficult to understand

Additional Context

  • Claude Code 2.1.296 (type declarations written by 2.1.295), Windows 11.
  • Related: #100984.

View original on GitHub ↗