For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

Agent Rules / Dispatch and reflection

How should plugin registries be written for coding agents?

Rule AR-09

For open sets (plugins), register explicitly into a typed registry next to a typed contract.

Some sets can't be closed at write time: export formats, importers, commands that other packages add. Those calls stay dynamic, and that is fine. What an agent needs is a place to find the members and a type that says what each member must look like. An explicit register(...) call, a registry with a typed value, and a Protocol or interface for the contract give it both.

Why it happens

Python. A registry filled by naming convention or by import side effects leaves the membership list in no file. __init_subclass__ is called on a parent class whenever a subclass is created, so a base class can append every subclass to a list. That is convenient, and it also means the registry lists only the subclasses that have been imported by the time you read it (the data model page says: "This method is called whenever the containing class is subclassed", with cls as the new subclass: https://docs.python.org/3/reference/datamodel.html#object.__init_subclass__). Packages can also advertise plugins through entry points: a distribution lists name = module:object lines under a group in entry_points.txt, and importlib.metadata.entry_points(group=...) followed by EntryPoint.load() imports the object. In that setup the plugin list lives in installed metadata, not in your source.

typing.Protocol gives structural typing: a class matches if it has the right methods, with no inheritance. A registry typed dict[str, Plugin] where Plugin is a Protocol makes each registered object checkable against the contract at the registration call, even though the lookup at the end is by string.

TypeScript and JavaScript. A Map<string, Handler> or a Record<string, Handler> with an interface for Handler does the same. The weak spot is a registry typed any, or Record<string, any>, since a call through it is a call on any.

Bad and good

An exporter system where formats are added by other modules.

python# bad: membership by import side effect and untyped lookup EXPORTERS = {} class Exporter: def __init_subclass__(cls, **kw): EXPORTERS[cls.__name__.lower()] = cls def export(fmt, rows): return EXPORTERS[fmt]().run(rows)
typescript// bad: a global bag of anything, filled from many files export const exporters: Record<string, any> = {}; export function exportRows(fmt: string, rows: Row[]) { return exporters[fmt].run(rows); }
python# good: explicit register call, typed contract, one owner from typing import Protocol class Exporter(Protocol): def run(self, rows: list[Row]) -> bytes: ... _exporters: dict[str, Exporter] = {} def register_exporter(name: str, exporter: Exporter) -> None: if name in _exporters: raise ValueError(f"exporter already registered: {name}") _exporters[name] = exporter def export(name: str, rows: list[Row]) -> bytes: return _exporters[name].run(rows) # exporters/setup.py def register_all() -> None: register_exporter("csv", CsvExporter()) register_exporter("json", JsonExporter())
typescript// good: explicit register call, typed contract, one owner export interface Exporter { run(rows: Row[]): Uint8Array; } const exporters = new Map<string, Exporter>(); export function registerExporter(name: string, e: Exporter): void { if (exporters.has(name)) throw new Error(`exporter already registered: ${name}`); exporters.set(name, e); } export function exportRows(name: string, rows: Row[]): Uint8Array { const e = exporters.get(name); if (!e) throw new Error(`unknown exporter: ${name}`); return e.run(rows); } // exporters/setup.ts export function registerAll(): void { registerExporter("csv", new CsvExporter()); registerExporter("json", new JsonExporter()); }

In the good versions, a search for register_exporter lists every member. Changing run is checked at each registration. An unknown name raises where it is looked up, with the name in the message.

Trade-offs

  • The call stays dynamic. _exporters[name].run(rows) still doesn't say which class runs. What the contract buys is that every possible target has the same checked shape, so the agent edits the contract and the checker lists each implementation that breaks.
  • Explicit registration costs a line per member. A decorator (@register("csv")) keeps that line next to the class. It is fine if the decorator returns the same class or function unchanged, with its types preserved; see AR-12 for the rules.
  • Auto-discovery has real uses. Third-party plugin ecosystems, where your code can't list the members, are what entry points are for. Keep the group name and the contract in your source, and document it. The registry then has an owner even though its members come from outside.
  • Registration order matters when two modules register the same name. Raise on duplicates, as above.
  • Where the registration runs is part of the design. If it happens as a side effect of importing a module, an agent that moves an import changes behaviour. Put it in a setup call (AR-06).

Enforce it

ToolSettingWhat it catchesChecked
pyrightreportUnknownMemberType, reportUnknownArgumentType, reportUnknownVariableType (error in strict)registries and lookups whose element type is unknown2026-10-30
pyrightreportUntypedFunctionDecorator (error in strict)a register decorator without type annotations, which obscures the decorated function's type2026-10-30
pyrightreportMissingParameterType (error in strict)register(name, obj) without annotations on obj2026-10-30
typescript-eslintno-explicit-any (in recommended)Record<string, any> registries2026-10-30
typescript-eslintno-unsafe-call, no-unsafe-member-access (in recommended-type-checked)calls through an any registry value2026-10-30
tsconfignoUncheckedIndexedAccessRecord<string, Exporter> lookups that may be undefined, so the check for a missing name is forced2026-10-30

No rule in the pages checked requires that a registry has one owner module. That one is a review habit; AR-20 states it.

Rule block for AGENTS.md / CLAUDE.md

markdown- Plugin and handler sets that are open at runtime go through one registry module: a typed `register_x(name, obj)` function, a Protocol/interface for the contract, and a setup call that registers everything. - Don't fill registries from `__init_subclass__`, import side effects or naming conventions. - Before changing a plugin contract, grep for every `register_x(` call and update each implementation. - Registries raise on duplicate and unknown names.

Edge cases

  • Entry-point plugins are listed in installed metadata, so the registry's full membership is known only at runtime. Document the group name next to the contract, and treat that as the one dynamic call site. Since Python 3.10, entry_points() accepts selection arguments such as group; older versions return a dict keyed by group.
  • Subclass registries. If you keep __init_subclass__, keep the registry attribute and the base class in the same file, annotate it, and write a test that imports every plugin module before asserting on the registry. See AR-21 and CH-03.
  • Protocol runtime checks. isinstance on a @runtime_checkable Protocol tests that the attributes exist, not that their signatures match.
  • Several registries in one module are fine. Several modules writing to the same registry are not; see AR-20.
  • Type parameters. A generic Registry[T] class is clearer than a module-level dict once there are two or three registries.

What LLMGRAPH shows

The registry lookup _exporters[name].run(rows) prints maybe_more (open: type unknown) for CsvExporter.run, before and after. register_exporter("csv", CsvExporter()) prints certain as a call of CsvExporter.

References

  • Python typing, Protocol: https://docs.python.org/3/library/typing.html
  • Python data model, __init_subclass__ and __getattr__: https://docs.python.org/3/reference/datamodel.html
  • Python packaging, entry points specification: https://packaging.python.org/en/latest/specifications/entry-points/
  • importlib.metadata, entry_points and EntryPoint.load: https://docs.python.org/3/library/importlib.metadata.html
  • Pyright configuration: https://raw.githubusercontent.com/microsoft/pyright/main/docs/configuration.md
  • TSConfig reference: https://www.typescriptlang.org/tsconfig/
  • typescript-eslint: https://typescript-eslint.io/rules/no-explicit-any/ · https://typescript-eslint.io/rules/no-unsafe-call/ · https://typescript-eslint.io/rules/no-unsafe-member-access/

Changelog

  • 2026-10-30: draft 1.
  • 2026-10-30: verify items resolved (docs check)
  • 2026-10-30: copy-gate wording fixes
  • AR-06 Why does my agent not see what importing this module does?
  • AR-07 Why does my coding agent miss calls made through getattr?
  • AR-08 How do I write dispatch that type checkers can follow?
  • AR-15 How do I make duck typing visible to type checkers?
  • AR-20 Where should a registry live so coding agents can find it?
  • AR-21 Do metaclasses hide code from coding agents?
  • CH-03 Why did my plugin registry lose a format after an edit?

Chimeras that use this rule

  • CH-03 Why did my plugin registry lose a format after an edit?

Last verified

For agentsthis page as Markdown/agent-readable/ar-09.mdllms.txt