Adding a Plugin
Adding a plugin is smaller than it sounds.
Most of the time, it is one ordinary Dart service plus a thin class that gives the runtime a name for the feature.
Here is the whole move up front:
class CasualPlugin extends SessionPlugin { @override PluginId get pluginId => const PluginId('casual');
@override void register(ScopedServiceRegistry registry) { registry.registerSingleton<Greeter>( const ServiceId('greeter'), () => CasualGreeter(), ); }}
class FormalPlugin extends SessionPlugin { @override PluginId get pluginId => const PluginId('formal');
@override void register(ScopedServiceRegistry registry) { registry.registerSingleton<Greeter>( const ServiceId('greeter'), () => FormalGreeter(), priority: Priority.elevated, // wins (beats Priority.normal default) ); }}
Future<void> runGreeterExample() async { final runtime = PluginRuntime(plugins: [CasualPlugin(), FormalPlugin()]) ..init(); final session = await runtime.createSession();
final greeter = session.resolve<Greeter>(const ServiceId('greeter')); print(greeter.greet('world')); // Good day, world.
await runtime.dispose();}That already buys you three things:
- the feature has a runtime identity
- the service lives inside an isolated session
- the runtime can later enable, disable, replace, or configure it
If your app has anything that looks like “open the thing”, “close the thing”, “have more than one open at once”, or “let the user choose which features apply”, that shell starts paying rent quickly.
Unpack the example
Section titled “Unpack the example”The notification feature is a good first shape because it is boring on purpose. No clever architecture yet. Just one service, one plugin, one session.
Start with the boring Dart class first:
/// A simple notification service that prints a message.class NotificationService { /// Sends [message] to the configured output channel. Future<void> send(String message) async { print(message); }}That is intentional. Plugin Kit does not require every useful class to inherit from something. A service can stay ordinary until it needs settings, identity, or lifecycle.
Wrap the feature in a plugin
Section titled “Wrap the feature in a plugin”Now give the feature an identity and register the service.
/// A plugin that gives the notification feature a runtime identity and/// contributes [NotificationService] to the session registry.class NotificationPlugin extends SessionPlugin { @override PluginId get pluginId => const PluginId('notifier');
@override void register(ScopedServiceRegistry registry) { registry.registerSingleton<NotificationService>( const ServiceId('notification_service'), () => NotificationService(), ); }}That is already a real plugin.
The important pieces are:
pluginIdis the runtime identity for the featureregister(...)contributes services to the session registryserviceIdis the slot other code resolves laterScopedServiceRegistryalready knows which plugin is registering
Pick the scope deliberately
Section titled “Pick the scope deliberately”Default to SessionPlugin.
A session is one isolated unit of work: one document, one chat, one workspace, one tenant, one sandbox. If your app opens more than one of the thing, the plugin belongs in the session scope.
Use GlobalPlugin when there should be exactly one of something for the whole
runtime:
- telemetry
- app-wide settings
- a shared auth client
- a coordinator that watches all sessions
Run it
Section titled “Run it”Add the plugin to a runtime, create a session, and resolve the service.
/// Demonstrates constructing a runtime, creating a session,/// resolving [NotificationService], and sending a notification.Future<void> runNotificationExample() async { final runtime = PluginRuntime(plugins: [NotificationPlugin()])..init();
final session = await runtime.createSession();
final notifications = session.resolve<NotificationService>( const ServiceId('notification_service'), );
await notifications.send('Build completed.');
await runtime.dispose();}You now have:
- a feature with a runtime identity
- a service registered in a named slot
- a session that owns an isolated registry
- a runtime that controls lifecycle and cleanup
That is the core move. Everything else in this folder is a refinement of that shape.
Reacting when the session is alive
Section titled “Reacting when the session is alive”register(...) says what the plugin provides. Once a session opens, your
code may need to:
- subscribe to events
- answer typed requests
- start background work
- resolve services registered by other plugins
The idiomatic home for that logic is a StatefulPluginService. Its
attach() runs with the session context already bound, and inside it
you can reach on, onRequest, onRequestSync, bind, and emit
on the service. The four subscription helpers auto-track their handles
and the framework cancels them after detach() returns, so you never
have to remember the cleanup step.
Here is the notification feature extended to react when a long-running task completes:
/// Reactive service: subscribes to [TaskCompleted] during attach and/// dispatches a notification via the registered [NotificationService]./// Subscriptions registered through [on] are cancelled on detach.class TaskCompletionWatcher extends StatefulPluginService { @override void attach() { on<TaskCompleted>((envelope) async { final notifications = resolve<NotificationService>( const ServiceId('notification_service'), ); await notifications.send('Task ${envelope.event.taskId} completed.'); }); }}
/// The same notification plugin, now also registering a reactive watcher./// No `attach(...)` override is needed: the base plugin walks every/// registered [StatefulPluginService] and attaches each one when the session/// opens.class ReactiveNotificationPlugin extends SessionPlugin { @override PluginId get pluginId => const PluginId('notifier');
@override void register(ScopedServiceRegistry registry) { registry.registerSingleton<NotificationService>( const ServiceId('notification_service'), () => NotificationService(), ); registry.registerSingleton<TaskCompletionWatcher>( const ServiceId('task_completion_watcher'), () => TaskCompletionWatcher(), ); }}The plugin class itself did not override attach(...). When the session
starts, the framework walks every winning StatefulPluginService the plugin
registered and attaches each one before invoking the plugin’s own
attach(...). The watcher subscribes to TaskCompleted from inside its
own attach(), and the framework cancels that subscription when the
service detaches.
You can still override the plugin’s own attach(...) when you actually
need it: resolving a service from a different plugin at startup,
coordinating across plugins, or starting a shared resource. Plugin.attach
and Plugin.detach are pure user hooks; the framework runs orchestration
(stateful service attach/detach, subscription cleanup) around them.
Keep the plugin class thin
Section titled “Keep the plugin class thin”A plugin class should mostly be wiring:
pluginIdregister(...)attach(...)detach(...)
When the class starts holding real behavior, move that behavior out.
| If the feature needs… | Put it in… |
|---|---|
| plain reusable behavior | an ordinary Dart class |
| typed settings and registry identity | PluginService |
| session lifecycle, subscriptions, timers, or mutable session state | StatefulPluginService |
| shared domain data used by many plugins | a custom context |
This is the difference between a plugin that stays readable and a plugin that slowly turns into a second application hidden inside one class.
Add settings when someone needs control
Section titled “Add settings when someone needs control”If your service needs runtime configuration, promote it to PluginService
and read from config.
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;}Settings target the plugin and service slot:
final settings = RuntimeSettings( plugins: { const PluginId('sql_language'): const PluginConfig(enabled: true), const PluginId('experimental_router'): const PluginConfig(enabled: false), }, services: { const PluginId('linter_suite').service('line_length_linter'): const ServiceSettings(config: {'max_line_length': 120}), PluginId.wildcard.service('agent_service'): const ServiceSettings( priority: 200, config: {'provider': 'openai'}, ), },);PluginId.service(...) returns a Pin for the slot owned by that plugin.
For runtime construction without the chain, use Pin('my_notifier', ['notification_service']) (or Pin.wildcard(['notification_service']) for
the wildcard form).
You do not need to hand-parse a map inside your app code. The registry injects the effective settings when the service is resolved.
Who is “someone”
Section titled “Who is “someone””Settings are user-facing by default. That is the headline use: let the person using your app decide how a feature behaves, through a settings screen or a config file they edit.
But nothing in the contract says settings have to come from an end user. The host app can inject them too, and that is completely fine. Plugin Kit treats both the same way.
User-facing example: API keys
Section titled “User-facing example: API keys”The canonical user-driven case is a service that needs a secret only the user can supply. Suppose your app has a chat plugin that talks to an external API, and that API wants a key:
/// A chat backend service that reads its API key and model from settings.class ChatBackendService extends PluginService { /// The API key read from injected settings. String? get apiKey => config.getString('api_key');
/// The model identifier read from injected settings. String get model => config.getString('model') ?? 'claude-opus-4-7';
/// Sends [prompt] to the configured upstream API. Future<ChatReply> reply(String prompt) async { final key = apiKey; if (key == null) { throw StateError('No API key configured.'); } // Call the upstream API with key and model. return ChatReply.stub(); }}
/// A minimal stub reply type for the chat backend example.class ChatReply { /// The reply text. final String text;
/// Creates a [ChatReply] with [text]. const ChatReply(this.text);
/// Returns a stub reply for demo purposes. factory ChatReply.stub() => const ChatReply('stub reply');}A settings UI (more on capabilities in a minute) can read the expected
shape, render a text field, and push the new value back into
RuntimeSettings.services. The plugin never has to know whether that
field came from a keychain, a dotfile, or a dialog the user just closed.
Developer-facing example: one plugin, many apps
Section titled “Developer-facing example: one plugin, many apps”Since settings are just a Map, the host app developer can inject them
directly. If you maintain a shelf of plugins shared across several apps,
this is how you flip the same plugin into different modes without
forking plugin code.
const exampleSettings = RuntimeSettings( plugins: {PluginId('linter_suite'): PluginConfig(enabled: true)}, services: { // Key is pluginId:serviceId in wire form, built via typed chain. },);Same plugin code, two runtimes, two behaviors, zero if (appName == 'A')
branches buried inside the plugin. The user-facing and developer-facing
cases can also mix: some config keys come from the app developer, others
from the end user, and they all land in the same config node on the
service.
Make it discoverable without instantiating it
Section titled “Make it discoverable without instantiating it”Sometimes the host app needs to know things about your service before deciding whether to touch it. What file formats does it support? Is it slow? Does it belong in the “experimental” pane of a settings screen? Does the CEO insist it appear first?
Capabilities are the answer. A Capability is an empty abstract base
class. You subclass it with whatever fields you want, attach instances
during registration, and the host reads them back through the registry
without instantiating the service.
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'), }, );}And on the reading side:
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>()}');}resolveCapability returns the capability instance, or null when the
slot is unregistered, every registration is disabled, or the current
winner does not carry that capability type. The same call serves as a
presence check and as access to capability fields like
SupportsFileFormats.extensions.
The runtime does not know what SupportsFileFormats or IsSlowCapability
mean. It just holds them. Defining a schema for your settings UI, tagging
slots for routing, marking which plugins the CEO loves: that is all up to
you. See Capabilities for the full reader-side
story.
A useful authoring checklist
Section titled “A useful authoring checklist”-
Name the feature.
Pick a short, stable
pluginId. Lowercase and underscores age well:notification,sql_language,markdown_preview. -
Pick the scope.
Use
SessionPluginfor document/chat/workspace behavior. UseGlobalPluginfor truly shared runtime behavior. -
Write the service first.
Keep it as a normal Dart class unless it needs settings or lifecycle.
-
Register one clear slot.
Prefer descriptive service ids like
notification_serviceover vague names likeservice. -
Put reactions in a service, not the plugin class.
Event subscriptions, typed request handlers, timers, and background tasks belong in a
StatefulPluginService’sattach(...), where subscriptions are tracked for you. Keep the plugin’s ownattach(...)for plugin-wide wiring like peer resolution. -
Promote only when needed.
Reach for
PluginService,StatefulPluginService, capabilities, and custom contexts when the feature earns them.