Plugin Services
Plugins are wiring; services are the meat. This page is about which kind of service holds the meat.
There are three tiers, escalating by which plugin_kit features the behavior actually needs.
| Tier | Use when |
|---|---|
| Plain Dart class (no plugin_kit base class) | the behavior does not need settings injection, lifecycle, or event subscriptions |
PluginService | the service should read injected RuntimeSettings.services config and have registry-stamped identity |
StatefulPluginService<Ctx> | the service needs attach() / detach(), a bound plugin context, auto-tracked subscriptions, and a place for context-bound state |
If your plugin class is getting fat, that is almost always a hint that the work belongs in one of these three tiers, not on the plugin. Plain classes register as factories (fresh instance per context.resolve<T>()) or as singletons when sharing the instance is safe. PluginService supports factory, singleton, and lazy-singleton registration. StatefulPluginService rejects factories and must register as a singleton or lazy singleton so the runtime can track lifecycle.
The plugin class stays focused on wiring, services carry settings, state, and long-lived behavior.
Plugin Services
Section titled “Plugin Services”PluginService gives you three things.
- Injected settings, accessible as a typed
ConfigNodeviaconfig. - The raw settings map as
settings, for when you need to pass it through. - Authoritative identity via
pluginIdandserviceId.
class ModelRouter extends PluginService { /// The default model name read from injected settings. String get defaultModel => config.getString('default_model') ?? 'gpt-4.1';
/// The temperature value read from injected settings. double get temperature => config.getDouble('temperature') ?? 0.7;}Stateful Plugin Service
Section titled “Stateful Plugin Service”StatefulPluginService extends PluginService with plugin lifecycle. It adds attach(), detach(), a bound context, and automatic subscription tracking. Use SessionStatefulPluginService or GlobalStatefulPluginService for the common scope aliases.
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'); }}attach(), detach(), and onSettingsInjected() are pure user hooks: the framework binds and unbinds this.context around them, cancels every tracked subscription and binding after detach() returns, and runs settings bookkeeping before onSettingsInjected() fires. Helpers (on, onRequest, bind, emit, resolve, etc.) read this.context implicitly, so subscriptions inside attach() are always bucketed correctly.
Reacting to settings changes
Section titled “Reacting to settings changes”Override onSettingsInjected() to react when settings change. By the time it runs, config and settings already hold the new values.
class CachedFormatter extends StatefulPluginService { String? compiledTemplate;
@override void onSettingsInjected() { compiledTemplate = null; }}Singletons and lazy singletons skip redundant re-injections via a settingsHash check; factory wrappers re-inject on every resolve.
Subscription tracking
Section titled “Subscription tracking”After importing plugin_kit, extension helpers appear on StatefulPluginService. Use those instead
of reaching straight for context.bus unless you have a reason not to.
The three that track their own subscriptions:
on<T>()onRequest<Req, Res>()onRequestSync<Req, Res>()
When detach() runs, every tracked subscription is cancelled for you. This is most of the point of
using a stateful service in the first place: the runtime can tear it down predictably
without you writing explicit cleanup code.
The extension also adds shorthand for the non-subscription actions you need day
to day: emit(...), resolve(...), maybeResolve(...), and resolveAfter(...). Those are covered
in the next section. To resolve a namespaced slot, build
the ServiceId via Namespace.call(...) and pass it to resolve(...) or maybeResolve(...).
Resolving and emitting
Section titled “Resolving and emitting”Stateful services also get short helpers for common runtime actions, so you do not
have to go through context for the normal cases.
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)); }); }}The service-level helpers are thin wrappers over the bound plugin context:
resolve(...)maybeResolve(...)resolveAfter(...)emit(...)
For namespaced slots, build a ServiceId via Namespace.call(...) and pass it to resolve(...) or maybeResolve(...).
The convenience emit(...) mirrors the underlying EventBus.emit signature: pass (event, {identifier}) and
await the returned EventEnvelope<T> to read the stopped flag, the identifier, or
the possibly-mutated payload.
Events and Event Bus cover what the envelope carries and when it matters.
How settings arrive
Section titled “How settings arrive”Settings are injected by the ServiceRegistry at resolution time, so:
- the service has to be resolved through the registry
- the service has to extend
PluginService - the settings key has to match the service’s slot
final serviceSettingsExample = RuntimeSettings( services: { const PluginId('model_router').service('decider'): const ServiceSettings( config: {'default_model': 'gpt-4.1-mini'}, ), },);class ModelRouter extends PluginService { /// The default model name read from injected settings. String get defaultModel => config.getString('default_model') ?? 'gpt-4.1';
/// The temperature value read from injected settings. double get temperature => config.getDouble('temperature') ?? 0.7;}Singletons and lazy singletons avoid redundant reinjection when the effective settings hash has not changed.
The case for stateful services
Section titled “The case for stateful services”The runtime runs the full plugin lifecycle on a toggle: attach re-runs on enable, detach re-runs on disable. Subscriptions registered directly from SessionPlugin.attach(context) via the auto-tracked helpers (on(context, ...), bind(context, ...)) are torn down for you on disable. So plugin-level subscriptions can stay correct without ceremony.
Stateful services pay for themselves when the work is more than wiring:
- The
on / onRequest / bindhelpers track subscriptions and bindings automatically. - The framework cancels every tracked subscription after
detach()returns. You never maintain a_subslist. - It separates wiring (the plugin class) from state (the service), which scales as the plugin grows.
That makes stateful services the natural home for anything with long-lived state:
- long-lived bus subscriptions
- timers
- context-bound caches
- mutable context state
If you find yourself writing final _subs = <EventSubscription>[]; on a SessionPlugin, that is usually the signal to promote the state to a stateful service.
Registration rule
Section titled “Registration rule”Register stateful services as singletons or lazy singletons.
registerSingleton(...)orregisterLazySingleton(...)
Factories are rejected. A factory would produce a fresh instance every resolve, and there is no way for the runtime to track or detach something it never got to see again.
Thin plugin, fat service
Section titled “Thin plugin, fat service”A clean default structure:
- Plugin class: wiring, ids, registration.
PluginService: configurable services.StatefulPluginService: context-bound behavior and subscriptions.
That keeps the plugin class small and makes lifecycle obvious.