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.
# 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)// 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);
}# 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())// 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
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| pyright | reportUnknownMemberType, reportUnknownArgumentType, reportUnknownVariableType (error in strict) | registries and lookups whose element type is unknown | 2026-10-30 |
| pyright | reportUntypedFunctionDecorator (error in strict) | a register decorator without type annotations, which obscures the decorated function's type | 2026-10-30 |
| pyright | reportMissingParameterType (error in strict) | register(name, obj) without annotations on obj | 2026-10-30 |
| typescript-eslint | no-explicit-any (in recommended) | Record<string, any> registries | 2026-10-30 |
| typescript-eslint | no-unsafe-call, no-unsafe-member-access (in recommended-type-checked) | calls through an any registry value | 2026-10-30 |
| tsconfig | noUncheckedIndexedAccess | Record<string, Exporter> lookups that may be undefined, so the check for a missing name is forced | 2026-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
- 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 asgroup; 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.
isinstanceon a@runtime_checkableProtocol 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_pointsandEntryPoint.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
Related
- 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