Skip to content

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.

PackageDescription
plugin_kitDart-only runtime: plugins, services, registry, event bus, settings, capabilities.
flutter_plugin_kitFlutter ergonomics: scope widgets, a State mixin that auto-cancels bus subscriptions, a ChangeNotifier adapter, and BuildContext.watchEvent / readEvent extensions. Optional.
plugin_kit_dialogFlutter 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.

Plugin Kit is a plain Dart package. Add it to your pubspec.yaml.

dependencies:
plugin_kit: ^PUBVER_plugin_kit

Find the latest version on pub.dev and add it to your dependencies.

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.

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.

  1. Define the contract.

    SystemPrompt is 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});
    }
  2. Define the implementations.

    DefaultSystemPrompt and ActivityAwareSystemPrompt are regular Dart classes. The runtime never inspects them. How they are constructed, mocked, or tested is entirely up to you.

  3. Define the plugins.

    A plugin has a unique PluginId and a register hook. 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 {
    @override
    PluginId get pluginId => const PluginId('chat');
    // ...
    }

    We extend SessionPlugin because 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.

  4. Register competing services.

    Both plugins register a SystemPrompt for the same slot, identified by const ServiceId('system_prompt'). ActivityAwarePlugin registers at Priority.elevated. ChatPlugin takes the default (Priority.normal). When the host resolves SystemPrompt, it gets the higher priority.

    The ScopedServiceRegistry already knows which plugin is doing the registering, so you only supply the ServiceId handle and a factory that constructs the instance.

    registry.registerSingleton<SystemPrompt>(
    const ServiceId('system_prompt'),
    () => ActivityAwareSystemPrompt(),
    priority: Priority.elevated,
    );
  5. Start the runtime.

    PluginRuntime is 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 the FeatureFlag.experimental flag; otherwise it ships off by default.

    final runtime = PluginRuntime(plugins: [ChatPlugin(), ActivityAwarePlugin()])
    ..init(
    settings: const RuntimeSettings(
    plugins: {PluginId('activity_aware'): PluginConfig(enabled: true)},
    ),
    );
  6. 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();
  7. Resolve and use.

    Ask for the service by its slot id. The session returns the winning implementation, which is ActivityAwareSystemPrompt in 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: '...')));
  8. 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();

The library only earns its weight when things change. Three quick experiments to convince yourself that something real is happening:

  • Drop ActivityAwarePlugin from the runtime’s plugin list (or flip the enabled flag to false) and re-run. Output flips to the default prompt. The host code did not change.

  • Lower ActivityAwarePlugin’s priority below the default (try priority: Priority.low or Priority.lowest) and re-run. Output flips back to the default. Same plugins, different winner.

  • Soft-disable ActivityAwarePlugin via config. Pass RuntimeSettings to 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.

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.