Skip to content

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.

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, not mk
GoodWhy
coreOne-word foundational plugin
model_routerTwo words, snake_case
legacy_anthropicModifier plus scope
firebase_mcpVendor plus capability
web_search_explorerDomain-shaped name
AvoidWhy
mkUnreadable abbreviation
MyPluginPascalCase clashes with the class name itself
modelRoutercamelCase reads inconsistently next to the snake_case rest of the runtime
model-routerHyphens are not the convention; the entire codebase uses underscores

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.

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 idWhy it fails
chat:chatDuplicates the domain; the service id contributes no information
chat:service, chat:managerGeneric words. Says it is a Plugin Kit slot. Says nothing about which slot
chat:chat_serviceRepeats both the domain and the framework type suffix

What works instead is a noun that narrows the role. The role can be:

Plugin idRole naming styleExample service id
chatEntity noun (the thing being held)chat:thread
editorEntity nouneditor:document
authEntity nounauth:session
linterFunctional rolelinter:engine
searchFunctional rolesearch:index
cacheFunctional rolecache:store
telemetryFunctional roletelemetry: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.

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.

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.

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:

NamespaceWhat lives there
agentModel, system message, temperature, anything LLM-shaped
mcpModel Context Protocol providers and transports
retryRetry policies, backoff strategies, circuit breakers
searchSearch providers (web, vector, hybrid)
systemApp-level singletons (info, environment, version)
panelPlugin-provided UI panels
GoodWhy
'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
AvoidWhy
'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

Events are plain Dart classes. The class name carries most of the meaning; field naming is regular Dart.

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>(...).

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.

GoodShape
UserMessageReceivedPast tense, fact
DocumentSavedPast tense, fact
SendNotificationImperative, command
FindOpenPortImperative, request
DraftOutgoingMessageDraft, mutable
CollectPanelsImperative collection; handlers append
AvoidWhy
NotificationAmbiguous; is it a fact or a command?
NotificationEventThe Event suffix adds nothing; the type is already on the bus
userMessageReceivedLowercase reads as a variable, not a type

Capabilities are subclasses of Capability. The class name describes what fact the tag asserts about a registration.

/// 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, not LanguageQuery)
  • end with Capability only when the bare name would be ambiguous
GoodWhat it says
UiConfigurableCapabilityService is editable in the dialog
SupportsLanguagesService handles these language codes
IsSlowCapabilityService has nontrivial latency
RequiresNetworkService makes network calls
AvoidWhy
LanguageCapabilitySays nothing about the fact; reader has to read the fields
SlownessReads as a quality, not a tag
CanBeSlowSubjunctive; IsSlow if it is currently slow

RuntimeSettings uses two key shapes that the dialog and the runtime both honor.

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.
},
);

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.

FormTargets
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).

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.

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

For hygiene, since these names appear repeatedly in the docs and example code:

VariableRefers to
runtimeA PluginRuntime
sessionA PluginSession
registryA ServiceRegistry or ScopedServiceRegistry, depending on context
busAn EventBus, usually context.bus
contextA PluginContext (or one of the typed subclasses)
settingsA RuntimeSettings

Following these consistently means a reader scanning code can guess types without inferring from method calls.