Event Bus
The EventBus is how plugins coordinate without holding references to each other. It is typed, priority-ordered,
and supports both fire-and-forget events and typed request/response. This page is the
model: envelope, priority direction, dispatch order, scope routing.
For the patterns that go on top (designing an event type, the pre-commit interception pattern, etc…), see Event Patterns.
The smallest possible event
Section titled “The smallest possible event”Define an event type. Emit it. Subscribe from somewhere that has no idea who the emitter is.
context.bus.on<UserLoggedInEvent>((env) { print('User logged in: ${env.event.userId}');});
await context.bus.emit<UserLoggedInEvent>(event: UserLoggedInEvent('u_123'));That is the whole happy path. Emit fires every matching handler in priority order. Emitters and subscribers never have to know about each other.
Every handler gets an envelope
Section titled “Every handler gets an envelope”There is one subscription API: on<T>(). Every handler you register receives an EventEnvelope<T>, not the raw payload. The envelope carries:
e.event: the payload (mutable; downstream handlers see changes).e.identifier: the optional scope this event was emitted under.e.stop(value): halt the cascade and pin the final payload tovalue.
A handler can read the payload, mutate it for downstream handlers, or call e.stop(...) to end the cascade. Event Patterns covers the practical shapes (observers, mutators, the pre-commit interception pattern) from inside plugin code; this page sticks to the model.
Priority
Section titled “Priority”Event handlers run in descending priority order. Higher number runs first.
context.bus.on<UserMessage>((env) { if (env.event.text.contains('spam')) { env.stop(const UserMessage(text: '[blocked]')); }}, priority: 0);
context.bus.on<UserMessage>((env) { print('Processing: ${env.event.text}');}, priority: 10);The highest-priority handler can mutate the payload so later handlers see the new value, or call e.stop(...) to halt
the cascade and pin the final result. Lower-priority handlers only run if no earlier one stopped. Default is Priority.normal; same convention and same default as the service registry.
Globally observing all events
Section titled “Globally observing all events”bind(...) registers a callback that sees every non-internal event on the bus before typed handlers run.
void Function() demonstrateBind(PluginContext context) { final unbind = context.bus.bind((envelope) { print('event ${envelope.event.runtimeType}'); }); return unbind;}Use it for tracing or analytics. It is not where business logic lives.
Think of it as a bus-wide tap that sees the pre-dispatch view of every event.
bind callbacks cannot stop the cascade; they only observe.
Request and response
Section titled “Request and response”The same bus also does typed request/response.
Future<SearchResults?> demonstrateRequest(PluginContext context) async { // Concession works regardless of whether [Response] is nullable; // return null to let the next handler claim. context.bus.onRequest<SearchQuery, SearchResults>((req) async { if (req.event.query.isEmpty) return null;
return SearchResults(results: ['result_${req.event.query}']); }, priority: 0);
// Canonical: maybeRequest returns null if no handler claims. Reach for // request only when at least one handler is guaranteed to claim. return context.bus.maybeRequest<SearchQuery, SearchResults>( const SearchQuery(query: 'dart patterns'), );}The dispatch model:
- Merge general handlers with handlers scoped to the request’s identifier (if any).
- Try them in descending priority order (highest first).
- The first non-null response wins.
That is why Response types are usually nullable. return null means “I concede, let the next handler try.”
Returning a value short-circuits the rest.
For strictly synchronous dispatch, use onRequestSync / requestSync.
For callers who prefer null over an exception when no handler responds, maybeRequest and maybeRequestSync exist too.
For a worked LLM-shaped example of priority-based request dispatch (provider routing, capability matching, fallback cascades), see the model_embassy tour.
Identifier scoping
Section titled “Identifier scoping”Handlers and emissions can be scoped to an identifier. This is how you multiplex one bus across many logical targets (tools, agents, panels, departments, per-document channels) without needing a separate bus for each.
Future<void> demonstrateIdentifierScoping(PluginContext context) async { context.bus.on<ToolExecutionEvent>((env) { print('saw tool execution: ${env.event.toolName}'); });
context.bus.on<ToolExecutionEvent>((env) { print('calculator specifically: ${env.event.toolName}'); }, identifier: 'calculator');
await context.bus.emit<ToolExecutionEvent>( event: const ToolExecutionEvent(toolName: 'calculator'), identifier: 'calculator', );}When you emit with an identifier, the bus merges general handlers for that type with handlers registered for that exact identifier. Handlers scoped to a different identifier do not see the event at all.
Global bus and session buses
Section titled “Global bus and session buses”Plugin Kit has two event scopes:
- One global bus owned by the runtime.
- One session bus per active session.
The two are isolated. Events emitted on the global bus do not automatically reach session buses, and session events do not escape their session. Cross-scope communication is always explicit.
- Global plugin → global plugins:
context.bus.emit(...) - Session plugin → same-session plugins:
context.bus.emit(...) - Session plugin → global scope:
context.globalBus.emit(...) - Global plugin → every active session:
context.sessions.emit<T>(event)(via theSessionBroadcastextension onGlobalPluginContext.sessions)
That isolation is intentional. It keeps event routing auditable, which matters once you have more than a handful of plugins across the two scopes.
See Sessions for the full story on cross-scope communication.
emit returns the envelope
Section titled “emit returns the envelope”emit returns EventEnvelope<T>, not Future<void>. If you do not care about interception, ignore the return value and move on. If you do care:
Future<void> demonstrateEmitEnvelope(PluginContext context) async { final result = await context.bus.emit<BeforeSaveEvent>( event: const BeforeSaveEvent(documentId: 'doc_1'), );
if (result.stopped) { print('Save was blocked or replaced: ${result.event}'); }
final BeforeSaveEvent payload = result.event; print('Final document ID to save: ${payload.documentId}');}The envelope carries whatever the handler chain ended up with: the possibly-mutated payload, whether it was stopped, and (if so) the final value.
Internal events
Section titled “Internal events”emitInternal(...) dispatches to handlers but skips bind observers. Most application code
never needs this. It exists for system-level signals you want typed handlers to react to
without cluttering analytics and logging taps.
Errors propagate
Section titled “Errors propagate”The bus does not swallow handler exceptions. If a handler throws during
emit, request, or requestSync, the caller sees the exception. That is almost always the right
tradeoff: silent failure in an event pipeline is much harder to debug a month later than a loud,
unambiguous exception at the call site.