DeepSeek Harness: Event System
Last updated: 2026-08-31
Events are Cordis's core mechanism for loosely-coupled inter-plugin communication — plugins don't call each other directly, but collaborate through event broadcasting and subscription. Four event patterns cover all scenarios from simple notifications to complex pipelines.
📋 Prerequisites: Completed 14-inject.md, understand dependency injection
1. What You'll Learn
- emit: general event broadcasting
- bail: interruptible events
- serial: sequential events
- waterfall: chain-passing events
- Event domains: session/agent/capability
- Custom events and type safety
2. emit: General Event Broadcasting
(1) Basic Usage
emit is the simplest event pattern — broadcast a notification; all listeners receive it, return values are ignored:
// Emit event
ctx.emit('session/created', { id: 'abc-123', user: 'Alice' })
// Listen to event
ctx.on('session/created', (data) => {
ctx.logger.info(`new session: ${data.id}`)
})
(2) Characteristics
| Characteristic | Description |
|---|---|
| Broadcast | All listeners are called |
| No return value | Listener return values are ignored |
| No interruption | Listeners cannot prevent subsequent listeners from executing |
| Sequential | Listeners execute in registration order |
(3) Typical Scenarios
- Notifications:
session/created,plugin/loaded - Logging:
tool/executed,llm/request - Statistics:
request/completed,error/occurred
(4) ▶ Example 4
ctx.on('session/created', (data) => {
ctx.logger.info(`logger: ${data.id}`)
})
ctx.on('session/created', (data) => {
ctx.metrics.inc('session_count')
})
ctx.on('session/created', (data) => {
ctx.cache.set(`session:${data.id}`, data)
})
ctx.emit('session/created', { id: 'abc', user: 'Alice' })
// All three listeners execute
3. bail: Interruptible Events
(1) Basic Usage
bail is an interruptible event — when a listener returns a non-undefined value, subsequent listeners don't execute:
// Listener can "intercept" the event
ctx.on('tool/beforeExecute', (data) => {
if (data.tool === 'shell' && data.params.command.includes('rm')) {
return { denied: true, reason: 'dangerous command' }
}
})
ctx.bail('tool/beforeExecute', { tool: 'shell', params: { command: 'rm -rf /' } })
// → Returns { denied: true, reason: 'dangerous command' }
// Subsequent listeners don't execute
(2) Characteristics
| Characteristic | Description |
|---|---|
| Interruptible | Returning non-undefined interrupts propagation |
| Short-circuit | First listener to return a value terminates propagation |
| Has return value | bail call returns the intercepted value |
| Order-sensitive | Earlier-registered listeners intercept first |
(3) Typical Scenarios
- Permission checks:
tool/beforeExecute(deny dangerous operations) - Content filtering:
message/beforeSend(filter sensitive content) - Conditional skipping:
task/beforeRun(skip inapplicable tasks)
(4) ▶ Example 4
// Approval policy plugin
ctx.on('tool/beforeExecute', (data) => {
const policy = getApprovalPolicy(data.tool)
if (policy === 'deny') {
return { denied: true, reason: `${data.tool} is denied by policy` }
}
if (policy === 'ask') {
return { pending: true, requiresApproval: true }
}
// Return undefined → don't intercept, continue propagation
})
// Sandbox plugin (after approval)
ctx.on('tool/beforeExecute', (data) => {
if (!isInSandbox(data.params.cwd)) {
return { denied: true, reason: 'execution outside sandbox' }
}
})
(5) ▶ Example 5
const result = ctx.bail('tool/beforeExecute', data)
if (result) {
// Intercepted
ctx.logger.warn('tool execution denied:', result.reason)
} else {
// Not intercepted, can execute
await executeTool(data)
}
4. serial: Sequential Events
(1) Basic Usage
serial executes async listeners in order, each waiting for the previous one to complete:
ctx.on('session/initialized', async (data) => {
await loadUserPreferences(data.userId)
})
ctx.on('session/initialized', async (data) => {
await setupWorkspace(data.workspaceId)
})
ctx.on('session/initialized', async (data) => {
await warmCache(data.projectPath)
})
// Three listeners execute sequentially
await ctx.serial('session/initialized', { userId: 'alice', workspaceId: 'ws-1' })
(2) Characteristics
| Characteristic | Description |
|---|---|
| Sequential | Listeners execute one by one in order |
| Async | Supports async listeners |
| Waits for completion | serial call waits for all listeners to finish |
| No interruption | Listeners cannot prevent subsequent execution |
(3) Typical Scenarios
- Initialization flows:
session/initialized(load config → establish connections → warm cache in order) - Cleanup flows:
session/closing(save data → disconnect → clean temp files in order) - Data pipelines:
data/transform(transform data step by step)
(4) Difference from emit
// emit: parallel (doesn't wait)
ctx.emit('session/created', data) // Doesn't wait for listeners to complete
// serial: sequential (waits)
await ctx.serial('session/created', data) // Waits for all listeners to complete
5. waterfall: Chain-Passing Events
(1) Basic Usage
waterfall passes the previous listener's return value to the next, forming a chain:
ctx.on('message/format', async (data, next) => {
data.text = data.text.trim()
return next(data)
})
ctx.on('message/format', async (data, next) => {
data.text = data.text.replace(/\s+/g, ' ')
return next(data)
})
ctx.on('message/format', async (data, next) => {
data.text = data.text.substring(0, 4096)
return next(data)
})
const result = await ctx.waterfall('message/format', { text: ' hello world ' })
// result.text === 'hello world' (trim → collapse → truncate)
(2) Characteristics
| Characteristic | Description |
|---|---|
| Chain-passing | Previous output is next input |
| next() call | Listeners must call next() to pass to the next |
| Modifiable | Each listener can modify data |
| Interruptible | Not calling next() interrupts the chain |
(3) next() Function
Each waterfall listener receives a next parameter:
ctx.on('event/name', async (data, next) => {
// Modify data
data.field = newValue
// Call next to pass to next listener
return next(data)
// Not calling next → chain interrupted, data not passed further
})
(4) Typical Scenarios
- Message processing:
message/format(format → filter → truncate) - Request pipeline:
request/process(authenticate → authorize → process → log) - Data transformation:
data/transform(parse → validate → normalize → output)
(5) Interrupting the Chain
ctx.on('request/process', async (data, next) => {
if (!data.authenticated) {
return { error: 'unauthenticated' } // Don't call next, chain interrupted
}
return next(data)
})
6. Event Domains
(1) Domain Partitioning
Cordis events are partitioned by domain, separated with /:
session/created → session domain
session/destroyed → session domain
tool/beforeExecute → tool domain (capability subdomain)
tool/afterExecute → tool domain
agent/initialized → agent domain
llm/request → llm domain
(2) Core Event Domains
| Domain | Prefix | Typical Events |
|---|---|---|
| session | session/ |
created, destroyed, forked |
| agent | agent/ |
initialized, stopped, error |
| tool | tool/ |
beforeExecute, afterExecute, error |
| llm | llm/ |
request, response, stream, error |
| fiber | fiber/ |
created, active, disposing, disposed, errored |
| config | config/ |
updated, validated |
(3) Domain Purpose
Event domains aren't syntactic sugar — the framework optimizes based on domains:
- Event filtering: Subscribe only to events in a specific domain
- Scope isolation: session domain events propagate within session contexts
- Audit grouping: Collect event logs by domain
(4) Subscribing to a Specific Domain
// Subscribe to all events in the tool domain
ctx.on('tool/*', (eventName, data) => {
ctx.logger.info(`tool event: ${eventName}`)
})
7. Custom Events and Type Safety
(1) Declaring Custom Events
// events.ts
interface MyPluginEvents {
'my-plugin/data-loaded': { source: string; count: number }
'my-plugin/data-error': { source: string; error: Error }
}
declare module '@deepseek-ai/cordis' {
interface Events extends MyPluginEvents {}
}
(2) Type-Safe Event Emission
ctx.emit('my-plugin/data-loaded', { source: 'api', count: 42 }) // ✅ Type correct
ctx.emit('my-plugin/data-loaded', { wrong: true }) // ❌ Type error
(3) Type-Safe Listening
ctx.on('my-plugin/data-loaded', (data) => {
// data automatically inferred as { source: string; count: number }
ctx.logger.info(`loaded ${data.count} items from ${data.source}`)
})
(4) Event Type Definition Pattern
// Plugin internal events
interface InternalEvents {
'cache/hit': { key: string; age: number }
'cache/miss': { key: string }
'cache/evicted': { key: string; reason: string }
}
// Extend global Events interface
declare module '@deepseek-ai/cordis' {
interface Events extends InternalEvents {}
}
// Export for other plugins to use
export type CacheEvents = InternalEvents
(5) Event Naming Conventions
{domain}/{verb-past-tense} ✅ session/created
{domain}/{verb-present} ✅ tool/execute (in progress)
{domain}/before{Action} ✅ tool/beforeExecute (pre-hook)
{domain}/after{Action} ✅ tool/afterExecute (post-hook)
{domain}/{noun}-{state} ✅ fiber/errored (state)
❓ FAQ
ctx.off() to remove — it requires the same function reference.typescript ctx.logger.info('listeners:', ctx.listenerCount('tool/beforeExecute')) 📖 Summary
- Four event patterns: emit (broadcast), bail (interruptible), serial (sequential async), waterfall (chain-passing)
- emit suits notifications, bail suits interception, serial suits ordered initialization, waterfall suits data pipelines
- Event domains separated by
/: session/agent/tool/llm/fiber/config - Custom events achieve type safety through
declare moduleextending the Events interface - waterfall's next() call is critical for chain propagation; forgetting to call interrupts the chain
📝 Exercises
1. ⭐ Basic: Write a plugin that broadcasts a my-plugin/loaded event via emit, and listen to it in another plugin with log output.
2. ⭐⭐ Intermediate: Implement an approval interceptor using bail on tool/beforeExecute to intercept shell commands containing rm. Test: ls executes normally, rm -rf / is intercepted.
3. ⭐⭐⭐ Challenge: Use waterfall to implement a message processing pipeline: trim → remove sensitive words → truncate oversized text. Each step is an independent listener; intermediate steps can modify data, and the last step returns the final result. Write tests to verify pipeline behavior.