Skip to content

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.

TierUse when
Plain Dart class (no plugin_kit base class)the behavior does not need settings injection, lifecycle, or event subscriptions
PluginServicethe 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.

PluginService gives you three things.

  • Injected settings, accessible as a typed ConfigNode via config.
  • The raw settings map as settings, for when you need to pass it through.
  • Authoritative identity via pluginId and serviceId.
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;
}

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.

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.

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

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.

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 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 / bind helpers track subscriptions and bindings automatically.
  • The framework cancels every tracked subscription after detach() returns. You never maintain a _subs list.
  • 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.

Register stateful services as singletons or lazy singletons.

  • registerSingleton(...) or
  • registerLazySingleton(...)

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.

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.