Naming Conventions
Plugin Kit does not enforce names beyond uniqueness, but consistent naming is the difference between a runtime you can reason about and one you cannot. This page is the style guide the existing docs and demo follow.
Deviating from these conventions is fine when you have a reason. The cost is mostly that consistency saves the next reader (often you, six months later) one lookup.
Plugin IDs
Section titled “Plugin IDs”Anatomy
Section titled “Anatomy”A plugin id is a stable, lowercase, snake_case identifier for one feature. Wrap it in const PluginId('...') because PluginId is an extension type, and the const constructor gives you compile-time guarantees for switch statements and map keys.
/// A global plugin that exposes its id as a static constant.class CorePlugin extends GlobalPlugin { /// The canonical id for this plugin. static const id = PluginId('core');
@override PluginId get pluginId => id;
@override void attach(GlobalPluginContext context) {}}A static const id field next to the class is the canonical pattern. Host code refers to CorePlugin.id instead of typing the string each time, which keeps the registry’s plugin maps free of typos.
Things that should be true of every plugin id:
- lowercase with underscores for word separation
- stable: treat it as a public identifier. Renaming breaks settings JSON, host code that addresses the plugin by id, and any persisted state
- unique across the whole runtime (the runtime rejects duplicates regardless of scope)
- descriptive:
memory_keeper, notmk
Examples
Section titled “Examples”| Good | Why |
|---|---|
core | One-word foundational plugin |
model_router | Two words, snake_case |
legacy_anthropic | Modifier plus scope |
firebase_mcp | Vendor plus capability |
web_search_explorer | Domain-shaped name |
| Avoid | Why |
|---|---|
mk | Unreadable abbreviation |
MyPlugin | PascalCase clashes with the class name itself |
modelRouter | camelCase reads inconsistently next to the snake_case rest of the runtime |
model-router | Hyphens are not the convention; the entire codebase uses underscores |
Service IDs and namespaces
Section titled “Service IDs and namespaces”Service ids are how plugins claim slots in the registry. Two shapes ship with the library: root and namespaced. The registry only knows about ServiceId; the difference between “root” and “namespaced” is just whether the dotted key has a prefix.
The principle
Section titled “The principle”Plugin id and service id sit on different axes. The plugin id is the domain (“chat”, “search”, “auth”). The service id is the role inside that domain (“thread”, “engine”, “store”). Two ways to break this and what they cost:
| Bad service id | Why it fails |
|---|---|
chat:chat | Duplicates the domain; the service id contributes no information |
chat:service, chat:manager | Generic words. Says it is a Plugin Kit slot. Says nothing about which slot |
chat:chat_service | Repeats both the domain and the framework type suffix |
What works instead is a noun that narrows the role. The role can be:
| Plugin id | Role naming style | Example service id |
|---|---|---|
chat | Entity noun (the thing being held) | chat:thread |
editor | Entity noun | editor:document |
auth | Entity noun | auth:session |
linter | Functional role | linter:engine |
search | Functional role | search:index |
cache | Functional role | cache:store |
telemetry | Functional role | telemetry:reporter |
When the domain has a clean entity noun, use it. When it does not, use a specific functional noun (engine, store, index, reporter, provider). The reader of cache:store knows the slot stores cache entries. The reader of cache:service knows nothing.
Anatomy: root services
Section titled “Anatomy: root services”A root service id is a single snake_case string passed to a const ServiceId('...').
/// Demonstrates registering a Greeter under a simple root service id.void registerGreeter(ScopedServiceRegistry registry) { registry.registerSingleton<AppConfig>( const ServiceId('greeter'), () => AppConfig.load(), );}Use a root id when there is only one of the thing in the runtime, or when the slot’s name already encodes its domain.
Anatomy: namespaced services
Section titled “Anatomy: namespaced services”Most services in a real app are namespaced. The format is namespace.service_name. Both halves are lowercase snake_case. The full slot key in the registry is the dotted form.
The idiomatic way to produce one is to build it from a Namespace. The registry method itself is the same registerSingleton you use for root services; the namespace is just a way of composing the id.
/// Demonstrates registering a ModelClient under a namespace.void registerModelClient(ScopedServiceRegistry registry) { const agent = Namespace('agent');
registry.registerSingleton<AppConfig>( agent('model'), // ServiceId('agent.model') () => AppConfig.load(), );}That registration claims agent.model. Resolution and settings overrides both use the dotted form. agent('model') is a call-shorthand for agent.service('model'); reach for the explicit form when it reads better at the site.
Anatomy: namespaces
Section titled “Anatomy: namespaces”A namespace is a lowercase single word that groups related services. Treat it as a public surface: the dialog renders namespace headers in the Services and Advanced tabs by their literal name.
Namespaces the demo uses:
| Namespace | What lives there |
|---|---|
agent | Model, system message, temperature, anything LLM-shaped |
mcp | Model Context Protocol providers and transports |
retry | Retry policies, backoff strategies, circuit breakers |
search | Search providers (web, vector, hybrid) |
system | App-level singletons (info, environment, version) |
panel | Plugin-provided UI panels |
Examples
Section titled “Examples”| Good | Why |
|---|---|
'greeter' | Root, single domain |
'agent.model' | Clear namespace and role |
'agent.system_message' | Snake_case after the dot |
'mcp.firebase' | Vendor under a domain namespace |
'panel.terminal' | UI namespace, terminal panel |
| Avoid | Why |
|---|---|
'AgentModel' | PascalCase reads as a class, not a slot |
'agent-model' | Dot, not hyphen |
'agent.systemMessage' | Mixed casing |
'agent_model' | Use a namespace, not a flat name with an underscore |
Event types
Section titled “Event types”Events are plain Dart classes. The class name carries most of the meaning; field naming is regular Dart.
Anatomy: facts (past tense)
Section titled “Anatomy: facts (past tense)”For events that announce something that already happened, use past tense. The emitter is reporting; subscribers react.
/// An event emitted after the user logs in.class UserLoggedIn { /// The user identifier. final String userId;
/// Creates a [UserLoggedIn] event. const UserLoggedIn({required this.userId});}Make these immutable: const constructors, final fields.
Anatomy: requests and commands (imperative)
Section titled “Anatomy: requests and commands (imperative)”For events that carry a request, command, or instruction, use the imperative verb form. Subscribers do something with them, often answering through request<Req, Res>.
/// A command event requesting a notification be sent.class SendNotification { /// The notification message. final String message;
/// The target channel. final String channel;
/// Creates a [SendNotification] command. const SendNotification({required this.message, required this.channel});}Imperative names pair naturally with bus.request<SendNotification, bool>(...).
Anatomy: drafts (mutable, on purpose)
Section titled “Anatomy: drafts (mutable, on purpose)”The pre-commit interception pattern uses an event type whose payload is intentionally mutable so handlers can rewrite it before the cascade ends. Name them DraftXxx or XxxDraft so the mutability is obvious to anyone reading the type.
/// A mutable draft event for outgoing messages, allowing handlers to mutate/// or veto the payload before it is sent.class DraftOutgoingMessage { /// The current text of the draft, mutable by handlers. String text;
/// Metadata attached by handlers. final Map<String, String> metadata;
/// Creates a [DraftOutgoingMessage] with [text]. DraftOutgoingMessage(this.text) : metadata = {};}This is the only common shape where event payload fields are mutable. Every other event type stays immutable.
Examples
Section titled “Examples”| Good | Shape |
|---|---|
UserMessageReceived | Past tense, fact |
DocumentSaved | Past tense, fact |
SendNotification | Imperative, command |
FindOpenPort | Imperative, request |
DraftOutgoingMessage | Draft, mutable |
CollectPanels | Imperative collection; handlers append |
| Avoid | Why |
|---|---|
Notification | Ambiguous; is it a fact or a command? |
NotificationEvent | The Event suffix adds nothing; the type is already on the bus |
userMessageReceived | Lowercase reads as a variable, not a type |
Capability types
Section titled “Capability types”Capabilities are subclasses of Capability. The class name describes what fact the tag asserts about a registration.
Anatomy
Section titled “Anatomy”/// Capability advertising language support.class SupportsLanguages extends Capability { /// The supported language identifiers. final List<String> languages;
/// Creates a [SupportsLanguages] capability. const SupportsLanguages(this.languages);}Capabilities should:
- be
const-constructable so registration sets stay literal - name the fact, not the question (
SupportsLanguages, notLanguageQuery) - end with
Capabilityonly when the bare name would be ambiguous
Examples
Section titled “Examples”| Good | What it says |
|---|---|
UiConfigurableCapability | Service is editable in the dialog |
SupportsLanguages | Service handles these language codes |
IsSlowCapability | Service has nontrivial latency |
RequiresNetwork | Service makes network calls |
| Avoid | Why |
|---|---|
LanguageCapability | Says nothing about the fact; reader has to read the fields |
Slowness | Reads as a quality, not a tag |
CanBeSlow | Subjunctive; IsSlow if it is currently slow |
Settings keys
Section titled “Settings keys”RuntimeSettings uses two key shapes that the dialog and the runtime both honor.
Anatomy: plugin-level keys
Section titled “Anatomy: plugin-level keys”RuntimeSettings.plugins is a Map<PluginId, PluginConfig>. The key is a PluginId, not a string.
const exampleSettings = RuntimeSettings( plugins: {PluginId('linter_suite'): PluginConfig(enabled: true)}, services: { // Key is pluginId:serviceId in wire form, built via typed chain. },);Anatomy: service-level keys
Section titled “Anatomy: service-level keys”RuntimeSettings.services is a Map<Pin, ServiceSettings>. Pin is an extension type wrapping the canonical pluginId:serviceId (or *:serviceId) wire string. Two forms cover every override.
| Form | Targets |
|---|---|
Pin('pluginId', ['service', 'segments']) | One specific plugin’s registration for that slot |
Pin.wildcard(['service', 'segments']) | The current winning registration for that slot, whoever owns it |
In Dart code, prefer the typed-chain shorthand when you have the underlying handles in scope: pluginId.service(serviceId) or pluginId.namespace('agent').service('model').
/// Composing namespaced service ids.void demonstrateNamespaceComposition() { const agent = Namespace('agent'); final modelId = agent('model'); final scopeId = agent.child('system_prompt')('scope');
final settings = RuntimeSettings( services: { const PluginId('chat').namespace('agent').service('model'): const ServiceSettings(config: {'temperature': 0.7}), }, );
print('$modelId $scopeId ${settings.services.length}');}The wire form (used for JSON serialization, accessible via Pin.wire) is pluginId:serviceId (or *:serviceId for wildcards). The serviceId half is the full slot key, including any namespace (agent.model, not just model).
Anatomy: nested config keys
Section titled “Anatomy: nested config keys”Inside ServiceSettings.config, dotted keys write to nested maps automatically. The dialog’s ConfigField.key uses the same convention.
const serviceSettingsExample = ServiceSettings( config: {'channel': 'slack', 'max_retries': 3}, priority: Priority.elevated,);The runtime stores this as a nested Map<String, dynamic>. ConfigNode does flat _node[key] lookups, so getString('provider.api_key') only works when that literal dotted key exists.
Examples
Section titled “Examples”| Pin (wire form) | Meaning |
|---|---|
Pin('analytics', ['tracker']) (wire: analytics:tracker) | The analytics plugin’s tracker registration |
Pin.wildcard(['agent', 'model']) (wire: *:agent.model) | Whoever currently wins the agent.model slot |
Pin('core', ['system', 'info']) (wire: core:system.info) | Core’s system.info registration |
Variable names
Section titled “Variable names”For hygiene, since these names appear repeatedly in the docs and example code:
| Variable | Refers to |
|---|---|
runtime | A PluginRuntime |
session | A PluginSession |
registry | A ServiceRegistry or ScopedServiceRegistry, depending on context |
bus | An EventBus, usually context.bus |
context | A PluginContext (or one of the typed subclasses) |
settings | A RuntimeSettings |
Following these consistently means a reader scanning code can guess types without inferring from method calls.