Skip to content

Event Patterns

This page is the patterns side of the event bus: what to reach for from inside plugin code, and how to shape an event type.

The model itself (envelope, priority direction, identifier scoping, cross-scope routing, request/response dispatch) lives one page over in Event Bus. Read that first if you want the mechanics. This page assumes you already have them.

Events are how Plugin Kit graduates from “service registry with nicer manners” to “a runtime the product can feel.”

  • A send button can emit one draft action, and plugins can inspect it, enrich it, block it, or stream progress back into the same message bubble.
  • A feature can ask “who can answer this?” through a typed request instead of resolving a concrete service and hoping it is ready.
  • A shell can emit a collection event like CollectPanels or CollectToolbarItems, and enabled plugins can append to the result in order.
  • A widget can stay dumb while plugins add richer behavior around it: context gathering, routing, logging, analytics, moderation, fallback, or whatever your app needs next year.

The idea is to separate your application from plugins even further. Your pretty abstraction layer is great with registry.resolve(), but what if your app didn’t even need to know about the registry and the services at all? What if the app just emitted events blindly and let plugins do the rest internally?

Other reasons you will want to reach for events: inter-plugin communication without direct dependency.

That is the vision for an event-driven plugin architecture, and it is what Plugin Kit supports.

Events are just Dart types.

/// An event emitted after the user logs in.
class UserLoggedIn {
/// The user identifier.
final String userId;
/// Creates a [UserLoggedIn] event.
const UserLoggedIn({required this.userId});
}

Two conventions that pay off over time:

  • Keep normal events immutable. For facts, commands, and requests, make the fields final and create a new object when you need a variant.
  • Name the verb deliberately. Past tense for things that already happened (UserMessageReceived, DocumentSaved, PaymentRejected). Imperative for request types (SendNotification, WaitForAssistant). The full naming guide lives in Naming Conventions.

The main exception is a deliberate pre-commit draft object emitted so handlers can rewrite it before some outside effect happens. The “pre-commit interception” section below covers when that exception is right and how to name it.

Inside a StatefulPluginService.attach(), use the service helpers instead of reaching into context.bus. They track subscriptions for you and cancel them on detach automatically.

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 three tracked subscription helpers:

HelperFor
on<T>()event handlers; observe, mutate, or stop the cascade
onRequest<Req, Res>()async typed request handlers
onRequestSync<Req, Res>()synchronous typed request handlers

Every on handler receives an EventEnvelope<T>. What the handler does with it decides the style:

  • Observe: read e.event, do side effects (log, count, update UI), return. The cascade keeps going.
  • Participate: mutate e.event in place so downstream handlers see the change, or call e.stop(value) to halt the cascade and pin the final result.

There is no separate API to opt into participation; the capability is always there in the envelope. If a handler does not need to mutate or stop, it simply does not.

You can subscribe directly from SessionPlugin.attach(...). After importing plugin_kit, Plugin itself gains the same auto-tracking helpers stateful services use: on<T>(), onRequest<Req, Res>(), onRequestSync<Req, Res>(), and bind(). Subscriptions and bindings are tracked per context and torn down automatically when the plugin detaches from that context. Plugin.attach and Plugin.detach are pure user hooks: no super call is needed. The framework runs orchestration (stateful service attach/detach, subscription cleanup) around your hook.

Plugin helpers take context as the first positional argument, since plugins are shared across sessions.

This shape is the right home for trivial wiring: a one-off debug log, a tracing tap, a small bridge between two services the same plugin owns. The auto-tracking means you do not have to remember the cleanup step, and the call site stays in the plugin class instead of growing a service for a single line.

class GreeterPlugin extends SessionPlugin {
@override
PluginId get pluginId => const PluginId('greeter');
@override
void register(ScopedServiceRegistry registry) {
registry.registerLazySingleton<GreeterService>(
const ServiceId('greeter_service'),
() => GreeterService(),
);
}
@override
void attach(SessionPluginContext context) {
on<UserJoinedEvent>(context, (event) {
final greeter = context.resolve<GreeterService>(
const ServiceId('greeter_service'),
);
greeter.sayHello(event.event.userId);
});
}
}

Reach for context.bus.on(...) only when you genuinely need to manage a subscription’s lifetime by hand: the bus call returns a EventSubscription you have to cancel yourself, and is not tracked.

From inside a stateful service, emit(...) is the short form. It sends the event on the session bus and completes when all handlers have run.

await emit(UserMessageReceived(
sessionId: currentSession,
text: 'Hello',
));

This is the right form for facts and one-way notifications. Most events you emit will look like this.

context.bus.emit<T>(...) returns an EventEnvelope<T>. You use this form when you want to let other plugins interfere with the event before you commit to it.

You emit what you intend to do. Handlers get a chance to rewrite the payload, enrich it, redact it, or veto the whole thing. You read the envelope back and act on whatever the chain produced. It is how you give plugins a seat at the table without calling them by name.

This is also the main exception to the immutability rule above: the payload is intentionally a draft object that handlers are allowed to edit in place.

/// A mutable draft event for outgoing messages, allowing handlers to mutate
/// or veto the payload before it is sent.
class DraftOutgoingMessage {
/// The current text of the draft, mutable by handlers.
String text;
/// Metadata attached by handlers.
final Map<String, String> metadata;
/// Creates a [DraftOutgoingMessage] with [text].
DraftOutgoingMessage(this.text) : metadata = {};
}

A handler can do two things to the payload:

  • Mutate the draft in place. Append context, redact PII, rewrite URLs, translate text. Return without calling stop and later handlers plus the emitter see the mutation. The cascade keeps running.
  • Replace or veto by calling e.stop(value). Ends the cascade right there, pins e.event to value, and flips e.stopped to true. No more handlers run.

Whichever path the handler takes, the envelope the emitter gets back is always the after view. envelope.event is the final payload. envelope.stopped tells you whether the cascade was cut short. envelope.identifier carries through whatever identifier the event was emitted under.

The pre-commit interception pattern is a contract between emitter and handlers: the emitter promises to honor whatever the chain produced, so plugins can change or block the event before it lands without the emitter having to call them by name.

The bus also does typed request/response. The mechanics are covered in Event Bus; here is the shape inside plugin code.

Future<void> demonstrateMultiSessionIsolation(PluginRuntime runtime) async {
final sessionA = await runtime.createSession();
final sessionB = await runtime.createSession();
// Each session has its own bus; events do not cross.
sessionA.bus.on<AppThemeChanged>((e) => print('A: ${e.event.theme.name}'));
await sessionA.bus.emit<AppThemeChanged>(
event: const AppThemeChanged(Theme(name: 'dark')),
);
// Session B's subscriber never fires.
await sessionA.dispose();
await sessionB.dispose();
}

Callers ask through the bus:

final port = await context.bus.request<FindOpenPort, int?>(
const FindOpenPort(),
);

The two patterns worth remembering when designing a request type:

  • Make Response nullable. return null means “I concede; let the next handler try.” That is how a chain of handlers falls through until one provides an answer. A non-nullable response forces every handler to win or throw.
  • Match request to maybeRequest based on caller expectations. request throws when nothing answers; maybeRequest returns null. Pick the one that matches what the call site actually wants to do on a missed handler.
  • Reading the envelope as if it were the payload. The callback gets EventEnvelope<T>, so the payload lives at e.event, not e. Accessing fields directly on the callback argument will not compile.
  • Forgetting the context argument on Plugin helpers. Plugin.on, onRequest, bind, and emit take context as the first positional arg (on<E>(context, (e) => ...)). Inside a StatefulPluginService the helpers read this.context instead, so the same call is on<E>((e) => ...). Mixing the two shapes is the easy mistake.
  • Non-nullable Response types. Request chains depend on null meaning “concede.” If every handler must return a value, you forfeit the fall-through pattern.
  • Leaning on context.bus.on inside a plugin or stateful service. You lose auto-tracking. Use the on(...) / onRequest(...) / onRequestSync(...) helpers; both Plugin and StatefulPluginService expose them after importing plugin_kit.
  • Putting real behavior in Plugin.attach. It works and the cleanup story is fine, but a handler attached on the plugin is bound to the plugin’s enabled state and cannot be overridden, prioritized, or tuned via RuntimeSettings.services. Only services live in the registry, and only services participate in priority/override. Use plugin-level subscriptions for trivial wiring; put anything you’d want to be replaceable in a StatefulPluginService.