Service Registry & Capabilities
This page is the curated reference for the registry surface: typed handles, the registry itself, the plugin-facing scoped wrapper, the three registration wrappers, resolution methods, the PluginService hierarchy, and capabilities. Reach for it when you want to know exactly which arguments are positional, which are named, and what a method actually returns.
The model is covered in Service Registry. This page is the API.
Typed handles
Section titled “Typed handles”The registry keys most types as plain strings under the hood. The public API wraps those strings in zero-cost extension types so the dot-separated namespace.id shape stays explicit.
PluginId
Section titled “PluginId”/// Demonstrates typed handle composition.void demonstrateTypedHandles() { // PluginId: identifies a plugin. const chatId = PluginId('chat');
// Namespace: groups related service slots. const agent = Namespace('agent');
// ServiceId: identifies a service slot. final modelId = agent('model'); const directId = ServiceId('greeter');
// Pin: pairs a plugin with a service slot. final pin = chatId.service(modelId); final wildcardPin = PluginId.wildcard.service('tools');
print('$chatId $agent $modelId $directId $pin $wildcardPin');}Wraps a plugin identifier. The extension type implements String, so a PluginId flows directly into any String-typed parameter, interpolation, or Map<String, T> lookup without unwrapping. Equality and toString() delegate to the wrapped string.
Namespace
Section titled “Namespace”/// Demonstrates typed handle composition.void demonstrateTypedHandles() { // PluginId: identifies a plugin. const chatId = PluginId('chat');
// Namespace: groups related service slots. const agent = Namespace('agent');
// ServiceId: identifies a service slot. final modelId = agent('model'); const directId = ServiceId('greeter');
// Pin: pairs a plugin with a service slot. final pin = chatId.service(modelId); final wildcardPin = PluginId.wildcard.service('tools');
print('$chatId $agent $modelId $directId $pin $wildcardPin');}Wraps a namespace name. The service(...) helper builds a namespaced ServiceId, and call(...) makes the namespace itself callable so agent('model') reads as a tiny constructor. child(...) produces a sub-namespace by appending a segment, so Namespace('a').child('b') is Namespace('a.b'). has(ServiceId) is the typed predicate for “does this id live under this namespace at any depth”; it returns true for direct children and nested descendants alike. Like the other handles, Namespace implements String.
ServiceId
Section titled “ServiceId”/// Demonstrates typed handle composition.void demonstrateTypedHandles() { // PluginId: identifies a plugin. const chatId = PluginId('chat');
// Namespace: groups related service slots. const agent = Namespace('agent');
// ServiceId: identifies a service slot. final modelId = agent('model'); const directId = ServiceId('greeter');
// Pin: pairs a plugin with a service slot. final pin = chatId.service(modelId); final wildcardPin = PluginId.wildcard.service('tools');
print('$chatId $agent $modelId $directId $pin $wildcardPin');}Wraps the registry key (namespace.id or just id). The extension type implements String, so a ServiceId is usable anywhere a String is expected. == against another ServiceId (or a raw String) compares the wrapped values directly.
The three derived getters cover the two ways code typically wants to slice a dotted id:
.namespacereturns the full prefix. ForServiceId('a.b.c')it returnsNamespace('a.b')..idreturns everything after the last dot. ForServiceId('a.b.c')it returns'c'..topNamespacereturns just the first segment. ForServiceId('a.b.c')it returnsNamespace('a'). Use this when UI grouping should flatten nested ids under their root.
/// Demonstrates ServiceId namespace/id/topNamespace getters.void demonstrateServiceIdGetters() { const greeter = ServiceId('greeter'); const agentTools = ServiceId('agent.tools');
// agentTools getters. final agentToolsNamespace = agentTools.namespace; // Namespace('agent') final agentToolsId = agentTools.id; // 'tools' final agentToolsTop = agentTools.topNamespace; // Namespace('agent')
const nested = ServiceId('a.b.c'); final nestedNamespace = nested.namespace; // Namespace('a.b') final nestedTop = nested.topNamespace; // Namespace('a') final nestedId = nested.id; // 'c'
print( '$greeter $agentToolsNamespace $agentToolsId $agentToolsTop ' '$nestedNamespace $nestedTop $nestedId', );}To produce a namespaced id, build it from a Namespace:
/// Demonstrates building a namespaced ServiceId from a Namespace.void demonstrateNamespaceBuild() { const agent = Namespace('agent');
final tools = agent.service('tools'); // ServiceId('agent.tools') final model = agent('model'); // call() shorthand → ServiceId('agent.model')
print('$tools $model');}ScopedServiceRegistry forwards the ServiceId straight through to the raw registry. Because the handle implements String, the dot separator stays an implementation detail at the call site.
ServiceRegistry
Section titled “ServiceRegistry”The raw, non-scoped registry. Every plugin gets a ScopedServiceRegistry view in its register hook, but the raw registry is what sits on PluginContext.registry and what tests construct directly.
class ServiceRegistry { static const int defaultPriority = Priority.normal; // 500
ServiceRegistry({List<LocalPluginOverride> overrides = const []}); ServiceRegistry.empty();
// ... register*, resolve*, listing, mutation}defaultPriority is Priority.normal (500). Higher wins. EventBus uses the same polarity and default.
Registration
Section titled “Registration”Three pairs, each with a flat and a namespaced variant. All registration methods take named arguments at this level.
| Method | Wrapper kind | When the instance is built |
|---|---|---|
registerSingleton | SingletonWrapper | At registration time, by the registry via create() |
registerLazySingleton | LazySingletonWrapper | On first resolve |
registerFactory | FactoryWrapper | On every resolve |
void registerSingleton<T extends Object>({ required PluginId pluginId, required ServiceId serviceId, required Factory<T> create, int priority = ServiceRegistry.defaultPriority, CapabilitySet capabilities = const {},}) {There are no *Namespace variants on the registry. Build a namespaced ServiceId via Namespace.call(...) (the call-shorthand agent('model')) or Namespace.service(...), then pass the resulting ServiceId to the regular register* method. That keeps the registry surface narrow: the registry stores ServiceId keys; the Namespace helper only composes them.
If the same pluginId registers the same serviceId twice, the second call replaces the first unless the existing registration is an attached StatefulPluginService, in which case registration throws ArgumentError. After insertion the candidate list is re-sorted by priority descending.
Resolution
Section titled “Resolution”The registry stores registrations as a sorted list per serviceId. Resolution picks the first enabled wrapper in the list. Disabled wrappers (skipped by an active LocalPluginOverride) are passed over.
| Method | Returns | Throws on miss |
|---|---|---|
resolve<T>(serviceId) | The instance for the winner | Yes (StateError) |
maybeResolve<T>(serviceId) | The instance, or null | No |
resolveAfter<T>({pluginId, serviceId}) | The next enabled instance after pluginId in the chain | Yes |
resolveRaw<T>(serviceId) | The winning RegistrationWrapper<T>, no instantiation | Yes |
maybeResolveRaw<T>(serviceId) | The winning wrapper, or null | No |
There are no *Namespace resolution helpers. Build a namespaced ServiceId from a Namespace (agent('model') or agent.service('model')) and pass it to resolve, maybeResolve, or resolveRaw.
resolveAfter is the chain-of-responsibility hook. The signature is named-only:
T resolveAfter<T>({ required PluginId pluginId, required ServiceId serviceId,}) {It locates the registration owned by pluginId, walks past it, and returns the next enabled wrapper’s instance. The caller’s own enabled state is not consulted, which is what makes it useful for a winning plugin to defer to whatever it overrode. If no later registration exists, or every later one is disabled, the error message distinguishes the two cases.
Listing and inspection
Section titled “Listing and inspection”These methods walk the registry without mutating it. Most return empty collections or null when nothing matches. listAllServices() calls resolve() for each id, so it can throw StateError when a slot has only disabled registrations.
| Method | Returns |
|---|---|
listAllServiceIds([PluginId? pluginId]) | Every registered ServiceId, optionally filtered to one plugin’s registrations. |
listAllServices() | Map<ServiceId, Object> of every slot to its resolved winning instance. Calls resolve for each id, so factories run, and throws StateError if a slot has only disabled registrations. |
resolveRaw<T>(ServiceId) / maybeResolveRaw<T>(ServiceId) | The winning RegistrationWrapper for one slot, no instantiation. Iterate listAllServiceIds() if you need every winner. |
listCapabilitiesOfNamespace(Namespace) | Union of every Capability on every wrapper in namespace.*. |
getRegistrations(ServiceId) | All wrappers for serviceId in priority order, or null if none registered. |
getRegistrationsOfType<T>(ServiceId) | Same, filtered to wrappers whose payload is T. |
getPluginServices(PluginId, {bool skipFactories = false}) | Every resolved instance owned by the plugin (used by the lifecycle to drive attach / detach on StatefulPluginService). |
didPluginRegisterServices(PluginId) | Whether the plugin currently has any registration in any slot. |
listCapabilitiesOfNamespace is the discoverability backbone for things like the dialog Services tab: it aggregates the metadata of every slot in a namespace without building a single instance.
For “is this plugin enabled,” reach for PluginRuntime.isPluginEnabled (settings-aware) or PluginRuntime.isPluginAttached (runtime-effective). The registry tracks registrations, not enablement; didPluginRegisterServices is a registration-presence check, not an enablement check.
Mutation
Section titled “Mutation”RegistrationWrapper? unregister({ required PluginId pluginId, required ServiceId serviceId,}) {unregister removes a single plugin’s registration. If the slot becomes empty, the key disappears from the registry. The runtime calls this during settings reconciliation when a plugin is disabled. To target a namespaced slot, build the ServiceId via Namespace.call(...) first.
updateSettings replaces the override list, restamps each existing wrapper’s effective priority from the new overrides, and re-sorts every registration list. Both enabled/disabled state changes and priority changes (plugin-specific and wildcard *) take effect on the next resolve, with no re-registration required. RegistrationWrapper.basePriority exposes the unmodified registration-time value separately for tests and tooling that need to distinguish “what the registrant asked for” from “what the active settings dictate.”
copy() produces an isolated snapshot: each wrapper is cloned (independent mutable priority, shared underlying instance / factory / capabilities) and overrides are copied by value. Subsequent updateSettings calls on the live registry do NOT mutate the snapshot. This is what makes oldContext (passed to Plugin.onPluginSettingsChanged) compare reliably against newContext.
Per-plugin scope
Section titled “Per-plugin scope”void useScopedRegistry(ServiceRegistry registry) { final scoped = registry.scopedFor(const PluginId('my_plugin')); scoped.registerSingleton<AppConfig>( const ServiceId('config'), () => AppConfig.load(), );}Returns a ScopedServiceRegistry that fills in pluginId on every registration call. Plugins always receive one of these in register, so they never type their own pluginId.
ScopedServiceRegistry
Section titled “ScopedServiceRegistry”The everyday surface. Plugins receive one of these in their register(ScopedServiceRegistry registry) override and never deal with the raw registry unless they have a reason to.
class ScopedServiceRegistry { final ServiceRegistry raw; final PluginId pluginId; final int? defaultPriority;
const ScopedServiceRegistry( this.raw, this.pluginId, { this.defaultPriority, });
ScopedServiceRegistry withPriority(int priority);
// register* methods below}The two big differences from the raw registry are positional arguments for the plain register* methods, and automatic pluginId scoping.
Positional registration
Section titled “Positional registration”void registerSingleton<T extends Object>( ServiceId service, Factory<T> create, { int? priority, CapabilitySet capabilities = const {},}) => raw.registerSingleton<T>( pluginId: pluginId, serviceId: service, create: create, priority: priority ?? defaultPriority ?? ServiceRegistry.defaultPriority, capabilities: capabilities,);The intent is dense plugin register overrides:
void registerAllThree(ScopedServiceRegistry registry) { registry.registerFactory<QueryBuilder>( const ServiceId('query_builder'), QueryBuilder.new, );
registry.registerLazySingleton<Database>( const ServiceId('main_db'), () => Database.connect(), );
registry.registerSingleton<AppConfig>( const ServiceId('config'), () => AppConfig.load(), );}priority is nullable. When omitted it falls back to defaultPriority (set by withPriority), and ultimately to ServiceRegistry.defaultPriority. The per-call value, when supplied, always wins.
Namespaced registration
Section titled “Namespaced registration”There are no *Namespace register methods. The ServiceId you pass already carries any namespace prefix, so the registry only needs the one method per registration mode.
/// 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(), );}withPriority
Section titled “withPriority”ScopedServiceRegistry withPriority(int priority);Returns a new scope whose positional register* methods default to priority instead of ServiceRegistry.defaultPriority (Priority.normal, 500). Per-call priority still overrides. Convenient for cascades:
class AnthropicService extends PluginService { /// The API key from injected settings. String get apiKey => config.getString('api_key') ?? '';
/// The temperature from injected settings. double get temperature => config.getDouble('temperature') ?? 0.7;}final ServiceRegistry raw;The escape hatch. Use it when you need to register on behalf of another plugin, inspect existing registrations, or call any of the listing methods that the scoped view does not re-export.
Registration wrappers
Section titled “Registration wrappers”Every entry in the registry is a RegistrationWrapper<T>. The base class is sealed; the three concrete subtypes pick how the instance gets built.
sealed class RegistrationWrapper<T extends Object> { final PluginId pluginId; final int basePriority; int get priority; // effective; mutated by wildcard overrides final CapabilitySet capabilities;
T provide();}Equality and hashCode are based on (pluginId, basePriority). The
effective priority getter starts equal to basePriority but the runtime
restamps it whenever a wildcard override applies, so two wrappers can have
the same identity yet different effective priorities at different points
in time. provide() is the single hook that turns a wrapper into an
instance.
| Wrapper | Built when | Built how often | Reach for it when |
|---|---|---|---|
SingletonWrapper<T> | At registration | Once, by the caller | The instance already exists, or eager construction is desired |
LazySingletonWrapper<T> | On first provide() | Once, by the registry | Setup is expensive but shared state is fine |
FactoryWrapper<T> | On every provide() | Per resolve | Service is cheap and stateless, or callers need fresh instances |
You normally do not construct wrappers directly; the registry does it for you. They show up in your code through resolveRaw, getRegistrations, and friends, where the pluginId, priority, and capabilities fields are what you actually want.
PluginService
Section titled “PluginService”Base class for services that participate in settings injection.
abstract class PluginService { late PluginId pluginId; late ServiceId serviceId;
Map<String, dynamic> get settings; String get settingsHash; ConfigNode config;
@nonVirtual void injectSettings(Map<String, dynamic> settings, {String? hash});
void onSettingsInjected() {}}Stamped identity
Section titled “Stamped identity”pluginId and serviceId are late and stamped by the registry on every resolve. They are only authoritative after resolution; reading either one inside a constructor or before the first resolve throws LateInitializationError.
The stamp happens in _provideAndInject inside ServiceRegistry, which is the single point of truth. Subclasses do not pass identity fields to super().
Settings access
Section titled “Settings access”settings is the raw injected map. Most code reads through config instead, which is a ConfigNode wrapping the same data with typed accessors:
class AnthropicService extends PluginService { /// The API key read from injected settings. String get apiKey => config.getString('api_key') ?? '';
/// The temperature value read from injected settings. double get temperature => config.getDouble('temperature') ?? 0.7;}settingsHash is a stable hash of the current settings. The registry uses it to skip redundant injectSettings calls on singleton and lazy-singleton wrappers when nothing changed.
Settings injection
Section titled “Settings injection”@nonVirtualvoid injectSettings(Map<String, dynamic> settings, {String? hash});
void onSettingsInjected() {}injectSettings is @nonVirtual. The registry calls it on resolve when an override applies; it updates settings / settingsHash / config, then fires onSettingsInjected(). Override onSettingsInjected to react. Singleton and lazy-singleton wrappers gate on settingsHash to skip redundant injections; factory wrappers run injection on every resolve.
StatefulPluginService
Section titled “StatefulPluginService”PluginService plus plugin lifecycle hooks and automatic subscription cleanup.
class DebugAdapter extends StatefulPluginService { @override void attach() { on<DebugEvent>((e) { print('debug action: ${e.event.action}'); }); }
@override Future<void> detach() async { print('adapter shutting down'); }}The type parameter is the PluginContext subclass the service expects, so domain-specific context fields stay typed.
attach and detach
Section titled “attach and detach”attach() and detach() are pure user hooks. The framework binds context before calling attach(), then calls detach() and unbinds the context, cancelling every subscription opened via the tracked helpers below; you do not call super.attach() or super.detach(). Both base implementations are no-ops.
Inside attach(), read the bound context via this.context (or just context). The helpers on, onRequest, bind, emit, resolve, and maybeResolve read this.context implicitly, so no parameter is needed.
Reading context outside the attach/detach window throws StateError. Use hasContext to guard if the timing might overlap.
Tracked subscription helpers
Section titled “Tracked subscription helpers”The StatefulPluginServiceHelper extension adds bus subscription helpers that auto-track. Subscriptions opened through these helpers land in activeSubscriptions and get cancelled by the framework when the service detaches.
EventSubscription on<E>(EventHandler<E> handler, {int priority = Priority.normal, String? identifier});
EventSubscription onRequest<Request, Response>( RequestHandler<Request, Response> handler, { int priority = Priority.normal, String? identifier,});
EventSubscription onRequestSync<Request, Response>( SyncRequestHandler<Request, Response> handler, { int priority = Priority.normal, String? identifier,});
void Function() bind(EventBindingCallback callback);
Future<EventEnvelope<T>> emit<T>(T event, {String? identifier});All five helpers return what callers might need to cancel early: a EventSubscription for on / onRequest / onRequestSync, a cancel callback for bind, and the resulting envelope for emit. All call sites assume attach has run; emit throws StateError when called outside the window.
The same helper also exposes resolve, maybeResolve, and resolveAfter so a stateful service can reach into the registry without typing context.registry first. To resolve a namespaced slot, build the ServiceId via Namespace.call(...) and pass it to resolve(...) or maybeResolve(...). See Event Bus & Events for the underlying bus contract.
class ConversationState extends StatefulPluginService { @override void attach() { on<UserMessage>((e) async { final memory = resolve<ConversationMemory>(const ServiceId('memory')); memory.append(e.event.text); await emit(MessageStored(e.event.text)); }); }}Capability and CapabilityLookup
Section titled “Capability and CapabilityLookup”Capabilities are metadata tags on the registration wrapper. They are read without instantiating the service.
Capability
Section titled “Capability”abstract class Capability { const Capability();}An empty extensible base. The dialog ships UiConfigurableCapability (in plugin_kit) for opting services into the dialog’s Services tab; host apps and plugins define their own beyond that. The convention is const constructors so capability sets stay literal:
void registerLinterWithCapabilities(ServiceRegistry registry) { registry.registerSingleton<CodeLinter>( pluginId: const PluginId('linter_suite'), serviceId: const ServiceId('linter'), create: () => CodeLinter(), capabilities: { const SupportsLanguages(['dart', 'js']), const PartOfASuiteOfTools('super_suite'), const CanBeSlow(true, reason: 'network round-trip per call'), }, );}CapabilitySet
Section titled “CapabilitySet”typedef CapabilitySet = Set<Capability>;A plain Set<Capability> aliased for readability. Always immutable in practice; register* methods accept const {} as the default.
CapabilityLookup
Section titled “CapabilityLookup”Two extension methods on Set<Capability>:
extension CapabilityLookup on Set<Capability> { T? getOfType<T extends Capability>(); bool hasType<T extends Capability>();}hasType<T>() returns whether the set contains at least one capability of type T. getOfType<T>() returns the first matching capability, or null. Together they cover the two questions you ask of a capability set:
void inspectWrapper(ServiceRegistry registry) { final wrapper = registry.resolveRaw<CodeLinter>(const ServiceId('linter')); final caps = wrapper.capabilities; final slow = caps.getOfType<CanBeSlow>(); print('slow reason: ${slow?.reason}'); print('has languages: ${caps.hasType<SupportsLanguages>()}');}The dialog uses exactly this pattern to decide whether a service deserves a config card and which icon to draw next to it. See Plugin Kit Dialog for the end-to-end story.