Skip to content

4. Events

Services support direct calls; events let a plugin announce something without knowing which plugins listen. The harness uses events for interactions such as tool results, model requests, and approval decisions.

Declare, emit, listen

Create stats.ts in tmp/cordis-tutorial — a service that counts things and announces each change:

ts
import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context {
    stats: StatsService
  }
  interface Events {
    'stats/report'(name: string, count: number): void
  }
}

export class StatsService extends Service {
  private counts = new Map<string, number>()

  constructor(ctx: Context) {
    super(ctx, 'stats')
  }

  bump(name: string) {
    const next = (this.counts.get(name) ?? 0) + 1
    this.counts.set(name, next)
    this.ctx.emit('stats/report', name, next)
  }
}

export const name = 'stats'

export function apply(ctx: Context) {
  ctx.plugin(StatsService)
}

The interface Events merge is the event-system twin of the interface Context merge from chapter 3: it declares the event name and its listener signature, so ctx.emit and ctx.on are fully typed. The namespace/action naming convention keeps the flat event namespace readable.

Create reporter.ts:

ts
import type { Context } from '@deepseek-ai/cordis'
import type {} from './stats.ts'

export const name = 'reporter'
export const inject = ['stats']

export function apply(ctx: Context) {
  ctx.on('stats/report', (name, count) => {
    console.log(`[stats] ${name} -> ${count}`)
  })
  ctx.stats.bump('tool_call')
  ctx.stats.bump('tool_call')
  ctx.stats.bump('prompt')
}

The import type {} from './stats.ts' line imports nothing at runtime; it exists so TypeScript sees the declaration merges. Compose and run:

yaml
- name: './stats.ts'
- name: './reporter.ts'
[stats] tool_call -> 1
[stats] tool_call -> 2
[stats] prompt -> 1

Because ctx.on() is an effect, the listener disappears with the plugin — no manual removeListener bookkeeping, ever.

Dispatch modes

emit is one of five dispatch modes. Which one an event uses is part of its contract — it decides whether listeners can return values, run concurrently, or short-circuit each other:

ModeCallSemantics
emitctx.emit(name, ...args)Synchronous broadcast; returned promises and values are not awaited or collected.
parallelawait ctx.parallel(name, ...args)All listeners run concurrently; awaited together.
serialawait ctx.serial(name, ...args)Listeners run in order, awaited; the first non-null/false/undefined return wins and stops the rest.
bailctx.bail(name, ...args)Synchronous version of serial.
waterfallctx.waterfall(name, ...args, next)Around-middleware; see below.

Every harness event documents its mode in the generated reference on its owning subsystem page.

Waterfall: transform or short-circuit

Waterfall is the mode that powers interception. Each listener receives the arguments plus a next() continuation; it can transform what next() returns, or return without calling next() and short-circuit the rest of the chain — what the Cordis docs call the veto. Create waterfall-demo.ts:

ts
import type { Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Events {
    'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
  }
}

export const name = 'waterfall-demo'

export function apply(ctx: Context) {
  // Listener 1: wrap the downstream result.
  ctx.on('demo/transform', async (input, next) => {
    const downstream = await next()
    return downstream.toUpperCase()
  })

  // Listener 2: short-circuit when it owns the decision.
  ctx.on('demo/transform', async (input, next) => {
    if (input.includes('blocked')) return '** blocked **'
    return next()
  })

  void (async () => {
    console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
    console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
  })()
}

Point cordis.yml at just this file and run:

HELLO
** BLOCKED **

Walk through the second line: listener 1 runs first, calls next(), which invokes listener 2; listener 2 sees blocked and returns without calling next() — the innermost default (the function passed to ctx.waterfall) never runs — and listener 1 uppercases the replacement message on the way out.

The discipline that follows: a waterfall listener that only observes or annotates must call next(); returning without it is a deliberate short-circuit. Forgetting next() in a logging listener silently swallows the default behavior for everyone downstream. It is a standing rule of this repository (waterfall semantics).

The harness uses waterfalls for decisions that cooperating plugins may wrap or answer: agent/request lets a plugin replace the model-call config, and approval/request lets a policy answer instead of the user.

Next: Configuration — plugin options from cordis.yml.