Events are Cordis’s core communication mechanism and the key to Harness’s loosely coupled extension points: the core emits events at key moments, and plugins listen in as needed, unaware of each other. This tutorial covers the basics of ctx.on and ctx.emit, the three advanced modes bail, serial, and waterfall, typing events with TypeScript, and observing built-in events like agent/* and tools/* — ending with a complete tool-logger plugin.
Why events
Both the Harness core and plugins need to communicate with each other. The most obvious approach — importing each other’s modules — creates hard coupling: change one interface and every dependent breaks, and load order becomes an implicit constraint. Events fully decouple “who fires” from “who responds”: the emitter does not know who is listening, and listeners do not know who emitted.
The value of events shows most clearly in extension points: the core emits events when a tool finishes, when the agent makes a request, and so on, and any plugin can hook in to observe or intervene. Adding a new observer requires no changes to existing code — this is the foundation of the Harness plugin ecosystem.
Listen and broadcast
Events have just two basic operations: ctx.on to listen, ctx.emit to fire. This minimal plugin registers a listener at load time and then emits the event once:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'event-demo'
export function apply(ctx: Context) {
// register a listener: called when the event fires
ctx.on('my-plugin/ready', (payload: { id: string }) => {
console.log('[event-demo] worker ready:', payload.id)
})
// emit the event: all listeners run synchronously, return values are ignored
ctx.emit('my-plugin/ready', { id: 'worker-1' })
}
ctx.emit is a synchronous broadcast: all listeners run in registration order, their return values are ignored, and emit itself returns only after every listener has finished. If a listener has heavy work to do, handle it asynchronously inside the listener rather than blocking the emitter.
Event names are free-form strings, but the Harness ecosystem follows the namespace/action convention (covered below). For events you emit yourself, use your plugin name as the namespace to avoid colliding with other plugins.
Four event modes
Beyond broadcast, Cordis offers three modes with return-value semantics. All four share the same registration API (ctx.on); the only difference is how the dispatching side treats listener return values:
We have already seen emit; the other three modes are broken down below.
bail: short-circuit validation
bail calls listeners in registration order; the first result other than null/false/undefined “wins” and no further listeners run. It suits veto-style validation chains: if any listener objects, the flow stops immediately.
export function apply(ctx: Context) {
// a result other than null/false/undefined wins; later listeners never run
ctx.on('content/check', (text: string) => {
if (text.includes('forbidden')) return 'blocked'
return null // pass it on to the next listener
})
// dispatching side: the first non-empty result wins
const verdict = ctx.bail('content/check', 'some user input')
if (verdict) {
console.log('content blocked:', verdict)
}
}
Note that the test is exactly null/false/undefined — returning 0 or an empty string also short-circuits. To “pass”, return null explicitly; it reads the clearest.
serial: ordered execution
serial also runs in registration order, but it awaits each listener’s async result: the next one starts only after the previous one has fully finished. Like bail, the first result other than null/false/undefined stops further execution. It suits phased initialization — work that must happen in order, where any phase can call a halt.
export function apply(ctx: Context) {
ctx.on('setup-phase', async (phase: string) => {
console.log('[setup] database phase:', phase)
await new Promise((resolve) => setTimeout(resolve, 100))
})
ctx.on('setup-phase', async (phase: string) => {
console.log('[setup] cache phase:', phase)
await new Promise((resolve) => setTimeout(resolve, 100))
})
// runs in registration order: the second listener starts only after the first finishes
await ctx.serial('setup-phase', 'boot')
}
waterfall: pipeline transforms
waterfall lets each listener wrap the downstream result: a listener must call next() to delegate downstream, then transform the result it gets back. The dispatcher provides the terminal default implementation, so the whole pipeline wraps itself like an onion.
export function apply(ctx: Context) {
// listener side: call next() to delegate downstream, then wrap its result
ctx.on('my-plugin/transform', async (input: string, next: () => Promise<string>) => {
const downstream = await next()
return downstream.trim()
})
// dispatching side: provide the terminal default implementation
const input = ' hello '
const output = await ctx.waterfall('my-plugin/transform', input, async () => input)
console.log(output) // "hello"
}
In one sentence: use emit for notifications, bail for vetoes, serial for ordered async phases, and waterfall for layered transforms.
Typed events
Event names are strings, so a typo only surfaces at runtime. Cordis supports TypeScript declaration merging on the Events interface, giving both ctx.on and ctx.emit type checking and payload inference:
declare module '@deepseek-ai/cordis' {
interface Events {
'my-plugin/ready': (payload: { id: string }) => void
'my-plugin/transform': (input: string, next: () => Promise<string>) => Promise<string>
}
}
Declaration merging can live in any .ts file and TypeScript picks it up automatically. Put it at the top of the plugin entry file or in a dedicated types.ts, maintained alongside the plugin — the event signature is your plugin’s public contract.
With types in place, the callback of ctx.on('my-plugin/ready', ...) is inferred as { id: string }, a wrong payload passed to ctx.emit fails at compile time, and refactoring an event signature is fully covered by the compiler.
Naming conventions and built-in events
Harness ecosystem event names follow the namespace/action convention, so the source of an event is obvious at a glance. Common built-in events include:
agent/step: the agent executed one step;agent/request: the agent made a model request;agent/request-error: a model request failed;tools/result: a tool finished executing;session/event: the unified outlet for durable session events.
Listening to built-in events is no different from listening to your own:
export function apply(ctx: Context) {
// fires every time the agent makes a model request
ctx.on('agent/request', () => {
console.log('[observer] agent request started')
})
// fires when a request fails
ctx.on('agent/request-error', (error) => {
console.error('[observer] request failed:', error)
})
}
export function apply(ctx: Context) {
ctx.on('session/event', (event) => {
// turn/*, step/*, tool/call, tool/result, compaction/*
// are session-event types, not same-named Cordis events
if (event.type.startsWith('turn/')) {
console.log('[observer] turn event:', event.type)
}
})
}
In practice: the tool-logger plugin
Putting it all together, here is a complete tool-logger plugin: it listens to tools/result and logs each tool call’s name and arguments, plus the first 100 characters of the output text.
import type { Context } from '@deepseek-ai/cordis'
export const name = 'tool-logger'
export function apply(ctx: Context) {
ctx.on('tools/result', (exec, result) => {
console.log('[tool-logger]', exec.name, JSON.stringify(exec.arguments))
// extract text blocks from the result and print the first 100 characters
const text = result.content
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('')
console.log('[tool-logger] output:', text.slice(0,100))
})
}
This plugin has no inject and no configuration — a single listener hooks it into the Harness tool flow. That is the power of loose coupling through events: the core has no idea it exists, yet it observes every tool call.
In a real scenario you could extend this skeleton with filtering (log only specific tools), persistence (write to a file or remote endpoint), or re-emit processed results as your own events for downstream plugins to consume.
Listeners are effects
A listener registered with ctx.on is an “effect” whose lifecycle is bound to the plugin: when the plugin unloads, the framework removes it automatically — no manual dispose logic needed.
import type { Context } from '@deepseek-ai/cordis'
export const name = 'tool-observer'
export function apply(ctx: Context) {
// the listener is registered through ctx, so its lifecycle is bound to the plugin
ctx.on('tools/result', (exec, result) => {
console.log('[tool-observer]', exec.name)
})
}
// when the plugin unloads, the listener above is removed automatically — no manual cleanup
This is why the guidance is always “register resources through ctx”: listeners, timers, commands — anything that goes through ctx is reclaimed on unload. Only resources created outside ctx (raw setInterval, external connections) need a cleanup function registered via ctx.effect.
Event system FAQ
How do I choose between emit, bail, serial, and waterfall?
Use emit to broadcast that something happened with no interest in results. Use bail when any listener should be able to veto the flow (the first result other than null/false/undefined wins). Use serial when multiple async listeners must finish one by one in registration order. Use waterfall when several plugins should progressively transform the same piece of data. Listener registration is identical in all four — the choice only depends on the semantics the dispatching side needs.
What happens if a waterfall listener forgets to call next()?
The pipeline short-circuits at that listener: downstream listeners and the default implementation provided by the dispatcher never run, and waterfall returns that listener’s result directly. This interception ability is intentional, but it is also the most common silent bug — when an event “does nothing”, first check that every listener calls next().
Why does ctx.on("turn/start") never fire?
turn/*, step/*, tool/call, tool/result, and compaction/* are durable session-event types, not same-named Cordis events. They all flow out through session/event — listen to it and dispatch on event.type instead.
Do registered listeners still fire after the plugin unloads?
No. Listeners registered via ctx.on are bound to the plugin lifecycle and are removed automatically on unload. Only resources created outside ctx (raw timers, external connections) can leak — register a cleanup function for them with ctx.effect.