Skip to content

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.

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.

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:

  • pluginId is the runtime identity for the feature
  • register(...) contributes services to the session registry
  • serviceId is the slot other code resolves later
  • ScopedServiceRegistry already knows which plugin is registering

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

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.

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.

A plugin class should mostly be wiring:

  • pluginId
  • register(...)
  • attach(...)
  • detach(...)

When the class starts holding real behavior, move that behavior out.

If the feature needs…Put it in…
plain reusable behavioran ordinary Dart class
typed settings and registry identityPluginService
session lifecycle, subscriptions, timers, or mutable session stateStatefulPluginService
shared domain data used by many pluginsa 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.

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.

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.

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.

  1. Name the feature.

    Pick a short, stable pluginId. Lowercase and underscores age well: notification, sql_language, markdown_preview.

  2. Pick the scope.

    Use SessionPlugin for document/chat/workspace behavior. Use GlobalPlugin for truly shared runtime behavior.

  3. Write the service first.

    Keep it as a normal Dart class unless it needs settings or lifecycle.

  4. Register one clear slot.

    Prefer descriptive service ids like notification_service over vague names like service.

  5. Put reactions in a service, not the plugin class.

    Event subscriptions, typed request handlers, timers, and background tasks belong in a StatefulPluginService’s attach(...), where subscriptions are tracked for you. Keep the plugin’s own attach(...) for plugin-wide wiring like peer resolution.

  6. Promote only when needed.

    Reach for PluginService, StatefulPluginService, capabilities, and custom contexts when the feature earns them.