Events: Let Plugins Cooperate Without Knowing Who Listens
Use typed events and five dispatch modes to understand how Cordis broadcasts facts, runs listeners concurrently, or hands over decisions.
The distinction: services are for direct calls; events notify unknown consumers. The dispatch mode defines waiting, return values, and short-circuit behavior.
A plugin does not need to know how many listeners exist. It emits a typed event, and other plugins subscribe within their own lifecycles.
01 / Declare a typed event
import type { Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Events {
'stats/report'(name: string, count: number): void
}
}
export function report(ctx: Context) {
ctx.emit('stats/report', 'tool_call', 1)
}Declaration merging gives ctx.emit and ctx.on the event name and payload types. It emits no runtime wiring; the connection still comes from registering emitters and listeners.
A listener can live in a completely separate plugin:
ctx.on('stats/report', (name, count) => {
console.log('[stats] ' + name + ' -> ' + count)
})ctx.on() is an effect owned by the current Fiber, so unloading the plugin removes the listener without a manual removeListener registry.
02 / Five modes are public semantics
| Mode | Call | Meaning |
|---|---|---|
emit | ctx.emit | Synchronous dispatch; ignore listener return values |
parallel | await ctx.parallel | Run all listeners concurrently and await them |
serial | await ctx.serial | Await in order; the first result other than null, false, or undefined wins |
bail | ctx.bail | Synchronous form of serial |
waterfall | ctx.waterfall | Listeners wrap downstream through next() |
Failure behavior is part of the mode too: emit does not await returned promises, although a synchronous listener throw escapes the call; parallel awaits all listeners and throws an AggregateError if any rejects; serial, bail, and waterfall do not swallow listener failures.
The caller cannot freely substitute a mode. The mode is part of the event’s public agreement and should be explicit in the event declaration or owning subsystem documentation.
03 / How events and services divide work
Services directly request a capability, such as ctx.tools.register() or ctx.sessions.fork(). Events expose facts or requests to independent extension points:
- Durable facts use
session/event. - Live Agent coordination uses
agent/*events. - Tool execution uses
tools/*events.
If a fact must survive a reload, put it in the session log. A transient event alone cannot replace durable recording.
04 / The complete path through a stats service
import { Service, type Context } from '@deepseek-ai/cordis'
export class StatsService extends Service {
constructor(ctx: Context) {
super(ctx, 'stats')
}
private counts = new Map<string, number>()
bump(name: string) {
const next = (this.counts.get(name) ?? 0) + 1
this.counts.set(name, next)
this.ctx.emit('stats/report', name, next)
}
}The provider updates the count and emits. The reporter listens and prints. Their connection is stats/report, not a file path shared between the two plugins.
Where to go next
Sources: events tutorial, Cordis Events implementation, and event domains in Harness architecture.
More Posts
Composition and HMR: Import New Code, Dispose the Old Fiber, Then Activate
Understand stable ids, configuration groups, and Cordis HMR’s unload-reload cycle, including how to diagnose plugins stuck in PENDING.
Configuration: Give Plugins Validated Options and Defaults
Define Cordis plugin configuration with Schemastery and see how defaults, path-aware errors, and FAILED Fibers prevent a sick start.
Services and Context: Share Capabilities Without Binding Implementations
Use Service, Context, and declaration merging to understand how providers, consumers, and scopes connect DeepSeek Harness capabilities.
Newsletter
Join the community
Subscribe to our newsletter for the latest news and updates