Getting Started
You will write a chat plugin shipping a default system prompt, an experimental plugin that A/Bs an alternative, and watch the runtime resolve to the winner without the call site ever branching.
Packages
Section titled “Packages”| Package | Description |
|---|---|
plugin_kit | Dart-only runtime: plugins, services, registry, event bus, settings, capabilities. |
flutter_plugin_kit | Flutter ergonomics: scope widgets, a State mixin that auto-cancels bus subscriptions, a ChangeNotifier adapter, and BuildContext.watchEvent / readEvent extensions. Optional. |
plugin_kit_dialog | Flutter customization UI on top of any PluginRuntime. Optional, but the visual proof. |
Both Flutter packages are optional. The runtime is the part you build on.
Install
Section titled “Install”Plugin Kit is a plain Dart package. Add it to your pubspec.yaml.
dependencies: plugin_kit: ^PUBVER_plugin_kitFind the latest version on pub.dev and add it to your dependencies.
dependencies: plugin_kit: git: url: https://github.com/SaadArdati/plugin_kit ref: mainUse this if you want to consume it directly from GitHub without waiting for a pub.dev release.
Then run dart pub get (or flutter pub get) and you are set.
If your shell is Flutter and you want a scope widget to carry the runtime/session through the tree (plus a State mixin that auto-cancels bus subscriptions), add flutter_plugin_kit the same way. The Dart-only setup works without it; flutter_plugin_kit covers what it adds and when it pays for itself.
If you want the visual customization UI as well, add plugin_kit_dialog to your pubspec.yaml the same way.
The runtime works without it; Plugin Kit Dialog covers when and how to mount it.
The whole example
Section titled “The whole example”Copy this into a fresh Dart file and run it. Walkthrough is below.
/// Stand-in user type for the system-prompt example.class User { /// Display name shown in the prompt. final String name;
/// Short summary of the user's recent activity. final String recentActivity;
/// Creates a stand-in [User]. const User({required this.name, required this.recentActivity});
@override String toString() => name;}
/// Contract a chat plugin asks the registry for. Multiple plugins can/// register their own implementation; the highest priority wins.abstract class SystemPrompt { /// Builds the prompt for [user]. String build({required User user});}
/// Boring default: ships with the chat plugin.class DefaultSystemPrompt implements SystemPrompt { @override String build({required User user}) => 'You are a helpful assistant. The user is $user.';}
/// Experimental variant: injects the user's recent activity.class ActivityAwareSystemPrompt implements SystemPrompt { @override String build({required User user}) => 'You are a helpful assistant. The user is ${user.name}. ' 'Recent activity: ${user.recentActivity}.';}
/// The chat plugin: ships the original system prompt at default priority.class ChatPlugin extends SessionPlugin { @override PluginId get pluginId => const PluginId('chat');
@override void register(ScopedServiceRegistry registry) { registry.registerSingleton<SystemPrompt>( const ServiceId('system_prompt'), () => DefaultSystemPrompt(), ); }}
/// A teammate's experimental plugin. Higher priority wins resolution./// Flagged experimental so it ships disabled by default; users opt in.class ActivityAwarePlugin extends SessionPlugin { @override PluginId get pluginId => const PluginId('activity_aware');
@override List<FeatureFlag> get featureFlags => const [FeatureFlag.experimental];
@override void register(ScopedServiceRegistry registry) { registry.registerSingleton<SystemPrompt>( const ServiceId('system_prompt'), () => ActivityAwareSystemPrompt(), priority: Priority.elevated, // wins (beats Priority.normal default) ); }}
Future<void> runSystemPromptExample() async { final runtime = PluginRuntime(plugins: [ChatPlugin(), ActivityAwarePlugin()]) ..init( settings: const RuntimeSettings( // Experimental plugins ship disabled; opt in here. plugins: {PluginId('activity_aware'): PluginConfig(enabled: true)}, ), ); final session = await runtime.createSession();
final prompt = session.resolve<SystemPrompt>( const ServiceId('system_prompt'), ); const user = User( name: 'Ada', recentActivity: 'Opened 3 PRs, reviewed 2 designs', ); print(prompt.build(user: user)); // → "You are a helpful assistant. The user is Ada. Recent activity: ..."
await runtime.dispose();}Output:
You are a helpful assistant. The user is Ada. Recent activity: Opened 3 PRs, reviewed 2 designs.Two plugins, one slot, one runtime, one winner. The experimental plugin won. The chat plugin’s default is still registered, sitting at the lower priority. Now the parts.
What each piece is doing
Section titled “What each piece is doing”-
Define the contract.
SystemPromptis the abstract type that the host code asks for. Plugin Kit does NOT require interfaces, but they pay off the moment more than one implementation is in play.abstract class SystemPrompt {String build({required User user});} -
Define the implementations.
DefaultSystemPromptandActivityAwareSystemPromptare regular Dart classes. The runtime never inspects them. How they are constructed, mocked, or tested is entirely up to you. -
Define the plugins.
A plugin has a unique
PluginIdand aregisterhook. The id has to be unique across the runtime; the register hook is where the plugin contributes things to the session.class ChatPlugin extends SessionPlugin {@overridePluginId get pluginId => const PluginId('chat');// ...}We extend
SessionPluginbecause this feature lives inside a session rather than at the application level. The other scope,GlobalPlugin, is for behavior shared across every session. More on the split in Plugins. -
Register competing services.
Both plugins register a
SystemPromptfor the same slot, identified byconst ServiceId('system_prompt').ActivityAwarePluginregisters atPriority.elevated.ChatPlugintakes the default (Priority.normal). When the host resolvesSystemPrompt, it gets the higher priority.The
ScopedServiceRegistryalready knows which plugin is doing the registering, so you only supply theServiceIdhandle and a factory that constructs the instance.registry.registerSingleton<SystemPrompt>(const ServiceId('system_prompt'),() => ActivityAwareSystemPrompt(),priority: Priority.elevated,); -
Start the runtime.
PluginRuntimeis the long-lived host for all your plugins.init()prepares the global scope. After that, it is ready to spawn sessions. The experimental plugin must be explicitly enabled in settings because of theFeatureFlag.experimentalflag; otherwise it ships off by default.final runtime = PluginRuntime(plugins: [ChatPlugin(), ActivityAwarePlugin()])..init(settings: const RuntimeSettings(plugins: {PluginId('activity_aware'): PluginConfig(enabled: true)},),); -
Open a session.
A session is an isolated execution scope with its own registry, event bus, and context. You can open as many as you want, and each one runs its session plugins independently of the others.
final session = await runtime.createSession(); -
Resolve and use.
Ask for the service by its slot id. The session returns the winning implementation, which is
ActivityAwareSystemPromptin this case. The call site never knows there was a competition.final prompt = session.resolve<SystemPrompt>(const ServiceId('system_prompt'));print(prompt.build(user: User(name: 'Ada', recentActivity: '...'))); -
Shut it down.
Dispose the runtime when you are done. Every attached plugin gets a chance to detach and clean up before the lights go out.
await runtime.dispose();
Try changing things
Section titled “Try changing things”The library only earns its weight when things change. Three quick experiments to convince yourself that something real is happening:
-
Drop
ActivityAwarePluginfrom the runtime’s plugin list (or flip theenabledflag tofalse) and re-run. Output flips to the default prompt. The host code did not change. -
Lower
ActivityAwarePlugin’s priority below the default (trypriority: Priority.loworPriority.lowest) and re-run. Output flips back to the default. Same plugins, different winner. -
Soft-disable
ActivityAwarePluginvia config. PassRuntimeSettingsto the runtime and mark the experimental plugin disabled. Both plugins still ship, both classes still exist, but the disabled one never wins:/// Same two plugins, but the user has flipped the experiment off via the/// settings UI. The runtime detaches the experimental plugin; the chat/// plugin's original prompt becomes the winner again. No code changes.Future<void> runSystemPromptDisableExample() async {final runtime = PluginRuntime(plugins: [ChatPlugin(), ActivityAwarePlugin()])..init();final session = await runtime.createSession();// User decides they prefer the original. Same build, same code path,// different runtime answer.await runtime.updateSettings(const RuntimeSettings(plugins: {PluginId('activity_aware'): PluginConfig(enabled: false)},),);final prompt = session.resolve<SystemPrompt>(const ServiceId('system_prompt'),);const user = User(name: 'Ada', recentActivity: 'irrelevant now');print(prompt.build(user: user));// → "You are a helpful assistant. The user is Ada."await runtime.dispose();}Output flips to the default prompt. This is the move a settings UI makes when a user turns a feature off: same build, same code path, different runtime answer. Settings can also be updated live mid-session, and the runtime will reconcile the registry around the change.
That swap, where the winner changes and the call site does not, is the bet the rest of the library makes. A model selector that follows the live winner, a settings dialog that reorders priorities, a session that loads a tenant-specific plugin set: all the same move at different scales.
If you want users to drive that swap themselves, Plugin Kit Dialog is a drop-in three-tab
customization UI that exposes plugin enablement, service config, and the priority registry directly. The same RuntimeSettings you
constructed by hand above is what the dialog produces and saves.
What you just built
Section titled “What you just built”A feature has an identity (activity_aware), a slot it claims (system_prompt), and a priority that says how seriously it wants to win. The runtime owns the lifecycle. The host code asks the session, not a concrete class. The default prompt is still registered and still reachable as a fallback via resolveAfter; it just lost the active slot.
Everything else in Plugin Kit layers on top of this: events flying between plugins, settings flipping things live, session-scoped state that cleans itself up, capabilities that let you discover what a service can do without building it. Same vocabulary, applied in different directions.