DSH plugin events: ctx.on, five modes, real harness events
The DSH plugin event system comes down to three things: declare, emit, and listen with ctx.on / ctx.emit, choose one of the five dispatch modes, and attach listeners to real DeepSeek Harness events — always inside apply. It is the core mechanism for communication between Cordis plugins, and DeepSeek Harness (DSH) uses it heavily for loosely coupled extension points; each of the five dispatch modes has its own contract, and picking the wrong one loses return values or scrambles order.
How to write DSH plugin events with ctx.on and ctx.emit
A DSH plugin event has three parts — a type declaration, a producer, and a listener — and declaration merging gives both ctx.emit and ctx.on full type information. The basic usage in the official docs is just two lines (source):
ctx.on('event-name', (payload) => {
// Handle the event.
})
ctx.emit('event-name', payload)
Add types with declaration merging
DeepSeek Harness uses TypeScript declaration merging to make events type-safe: declare once and both triggering and listening infer correctly. Extend the Events interface inside your plugin:
import '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Events {
'my-plugin/ready': (payload: { id: string }) => void
'my-plugin/check': (input: string) => boolean | undefined
'my-plugin/transform': (input: string, next: () => Promise<string>) => Promise<string>
}
}
Names follow the namespace/action convention, which keeps the event namespace flat and readable — for example agent/pre-step, agent/request, tools/result, and session/event (source).
How to choose among the five DSH plugin dispatch modes
Which mode an event uses is part of its contract: it decides whether listeners can return values, run concurrently, or short-circuit one another. The official docs list five modes (source):
| Mode | Call | Semantics |
|---|---|---|
| emit | ctx.emit(name, ...args) | Synchronous broadcast; does not wait or collect return values |
| parallel | await ctx.parallel(name, ...args) | All listeners run concurrently and are awaited together |
| serial | await ctx.serial(name, ...args) | Runs in order and awaits; the first non-null/false/undefined return value wins and stops the rest |
| bail | ctx.bail(name, ...args) | Synchronous version of serial |
| waterfall | ctx.waterfall(name, ...args, next) | Around middleware; can transform or short-circuit |
A quick rule of thumb: to merely notify others → emit; to take the first valid result → bail / serial; to wrap downstream return values or intercept → waterfall.
A waterfall listener must call next() or it short-circuits the chain
A waterfall listener must call next() to pass control downstream; not calling it is a deliberate short-circuit — an intentional behavior in the official docs that implements interception and gateway logic. Here is the official demonstration (source):
ctx.on('demo/transform', async (input, next) => {
const downstream = await next()
return downstream.toUpperCase()
})
ctx.on('demo/transform', async (input, next) => {
if (input.includes('blocked')) return '** blocked **'
return next()
})
That yields a discipline: a waterfall listener that only observes or annotates must call next(). Forgetting to call it silently swallows every downstream default; DeepSeek Harness uses waterfall for decisions that collaborating plugins may wrap or answer, such as agent/request replacing the model call configuration and approval/request letting a policy answer in place of the user.
DSH plugin real harness events: tool results, model requests, and approval
Real DeepSeek Harness events are the directly listenable ones such as tools/result, agent/request, and approval/request, and their listeners must always be registered inside apply — listeners registered with ctx.on() are effects removed automatically when the plugin unloads. The official log plugin is the smallest example, listening for tool calls and results (source):
import type { Context } from '@deepseek-ai/cordis'
import '@deepseek-ai/dsh-tools'
export const name = 'tool-logger'
export function apply(ctx: Context) {
ctx.on('tools/result', (exec, result) => {
console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`)
const text = result.content
.map(block => block.type === 'text' ? block.text : '')
.join('')
console.log(`[tool result] ${text.slice(0, 100)}`)
})
}
A listener is an effect: unload the plugin and it disappears. Listeners registered through ctx.on() are removed automatically when the plugin unloads, so never hand-write removeListener (source). This is the same theme as the development spec rule that every capability is registered through ctx.
Tell Cordis events apart from persisted session events
turn/*, step/*, tool/call, tool/result, and compaction/* are persisted session event types, not Cordis events of the same name. To observe them, listen on session/event and inspect event.type; by contrast, events such as tools/result and agent/request can be listened to directly.
Three self-checks after you finish: are all listeners inside apply; do event names follow namespace/action; and does every waterfall listener explicitly decide whether to call next() or short-circuit on purpose. To turn your extension point into an implementation others can replace, see capability seams; to ship it after development, follow packaging into a bundle and compare community event usage in DSH Plugin Hub.
FAQ
Writing a DSH plugin event takes three steps: declare the event signature with declaration merging, trigger it with ctx.emit, and listen with ctx.on. ctx.on registers listeners and ctx.emit broadcasts synchronously to all of them; both rely on the same type declaration, and event names follow the namespace/action form.
A DSH plugin event has five dispatch modes: ctx.emit broadcasts synchronously without collecting return values, ctx.parallel runs listeners concurrently and awaits them, ctx.serial runs them in order and awaits, ctx.bail is the synchronous version of serial, and ctx.waterfall is around middleware. Choosing the wrong mode loses return values or scrambles order.
A DSH plugin waterfall listener that does not call next() short-circuits the entire pipeline. Observers or annotators must call next() to pass their result downstream; only a listener that intends to intercept returns without calling it, and this is deliberate behavior in the official docs.
No — DSH plugin listeners registered with ctx.on() are effects and are removed automatically when the plugin unloads. So always register listeners inside apply; do not write cleanup logic by hand or register listeners outside apply.
To listen for DSH plugin tool results, use ctx.on('tools/result', handler); to observe persisted session events such as turn, step, and tool/call, listen on session/event and inspect event.type. Those are persisted session event types rather than Cordis events of the same name.
Related Terms
- ctx.on
- ctx.on is the entry point for registering a DSH plugin event listener; the listener is an effect and is removed automatically when the plugin unloads, so no manual removeListener is needed.— https://deepseek-harness.github.io/deepseek-harness/en/develop/framework/events
- waterfall
- waterfall is one of the five DSH plugin dispatch modes and is around middleware: a listener receives the arguments plus next(), can wrap the downstream return value, or can return without calling next() to short-circuit the whole chain.— https://deepseek-harness.github.io/deepseek-harness/en/develop/cordis-tutorial/04-events
- ctx.bail
- ctx.bail is the synchronous version of serial; listeners run in order and the first return value that is not null, false, or undefined becomes the final result and stops the remaining listeners.— https://deepseek-harness.github.io/deepseek-harness/en/develop/cordis-tutorial/04-events
- session/event
- session/event is the DSH plugin listening point for observing persisted session events; turn/*, step/*, and tool/call are persisted session event types rather than Cordis events of the same name, so listen on session/event and inspect event.type.— https://deepseek-harness.github.io/deepseek-harness/en/develop/framework/events
Sources
- DeepSeek Harness docs - Events· deepseek-ai
- DeepSeek Harness docs - Cordis tutorial: events· deepseek-ai