What Is Cordis
Before diving into mechanisms, let's answer the one question the previous article skipped: what is Cordis, where does it come from, and why did dsh adopt it?
It's Not Written by DeepSeek
Cordis is an independent open-source TypeScript plugin framework, not written by DeepSeek. It comes from the cordiverse community. DeepSeek vendored it into the dsh repository (under vendor/cordis/) and made some customizations on top.
Its stated purpose, from the official README:
a TypeScript plugin framework for applications that need explicit dependency injection, scoped services, lifecycle-managed cleanup, and optional configuration-driven loading.
In plain English: a framework for splitting an application into plugins, where each plugin loads on demand, cleans up automatically on unload, and communicates with other plugins through "services" and "events" instead of direct imports.
What Problem Does a Plugin Framework Solve
Imagine building an Agent system from scratch. The naive approach looks something like this:
// Everything hard-wired, direct imports everywhere
import { ToolsRegistry } from './tools'
import { SessionStore } from './session'
import { AgentLoop } from './agent-loop'
import { LLMAdapter } from './llm-deepseek'
const tools = new ToolsRegistry()
const session = new SessionStore()
const llm = new LLMAdapter()
const loop = new AgentLoop(tools, session, llm)
loop.run()
This works, but creates four problems:
-
Swapping a component is painful: replacing
LLMAdapterwith another provider means hunting down everyimportstatement -
Load order is manually enforced:
AgentLoopcan only be created after the other three are initialized — you have to get that right by hand - No hot reload: changing one config option means restarting the whole process
-
Cleanup is a nightmare:
sessionneeds to close its connection,loopneeds to stop its timers — who guarantees the order?
Cordis's solution: instead of importing directly, everything finds each other through a shared ctx container. Load order is derived automatically from dependency declarations. Every registration comes with a cleanup function that runs automatically on unload.
This is why dsh chose it: dsh needs hot reload and the ability to freely swap model providers, sandboxes, and tool implementations. Cordis was designed exactly for this scenario.
Five Lines to Understand the Core Idea
import { Context, Service } from 'cordis'
// 1. Create the root container (shared across the whole application)
const root = new Context()
// 2. Mount the tool registry as a plugin
// It registers as ctx.tools; other plugins can use ctx.tools without importing it
await root.plugin(ToolsService)
// 3. Mount the Agent loop as a plugin
// It declares inject: ['tools'], so it automatically waits for tools to be ready
await root.plugin(AgentLoop)
// 4. Unload one plugin
// Cordis automatically runs all cleanup; plugins that depend on it also unload
await root.plugin(ToolsService).dispose()
One ctx, everything mounted on it, components discover each other through ctx, cleanup unwinds automatically in reverse order — that's the entire Cordis mental model. dsh has dozens of plugins; every one of them is organized this way.
Why Learn Cordis First
dsh has no traditional "framework core." The model adapter is a plugin. The tool system is a plugin. The Agent loop itself is a plugin. Session logging, permission control, sandbox isolation — all mounted as plugins.
This isn't marketing hyperbole; it's the literal architecture. The official docs state it plainly:
There is no privileged core to patch: you extend dsh by mounting a plugin beside the others.
The framework driving all of this is Cordis. Once you understand Cordis, every extension mechanism in dsh becomes readable. This article walks through Cordis source code (in vendor/cordis/src/) to explain each of the five core concepts — not just what they are, but why they work the way they do.
I. Context: A Proxy, Not an Object
Every Cordis plugin receives a ctx parameter. That ctx isn't a plain object — it's a Proxy.
Source (vendor/cordis/src/context.ts):
constructor() {
// ...
// Wrap `this` in a Proxy so all property reads go through
// ReflectService.handler — the service lookup layer
const self = new Proxy<this>(this, ReflectService.handler)
this.root = self
// ...
}
Why a Proxy? Because dsh needs on-demand service resolution: when you write ctx.tools, you aren't reading a property from a plain object — you're triggering the proxy, which looks up the service registered under the name 'tools' in the registry.
This design solves a concrete problem: consumers don't need to import the provider.
// ❌ Traditional approach: direct import, tight coupling
import { ToolsService } from './tools-service'
const tools = new ToolsService()
// ✅ Cordis approach: resolve through ctx, decoupled
// ctx.tools is resolved at runtime through the proxy;
// the provider can be swapped without touching consumer code
ctx.tools.register(myTool)
When you swap the tool service implementation (say, switching to remote tool execution), not a single line in the consumers changes.
Contexts Are Hierarchical
Every plugin runs inside its own child Context. A child Context inherits the parent's services but can hold its own isolated service instances. This is the underlying mechanism that lets dsh give each Agent its own scoped tool set.
II. Plugin + Fiber: The Plugin Lifecycle
Three Plugin Forms
Cordis accepts three plugin forms:
import { Service, type Context } from '@deepseek-ai/cordis'
// Form 1: function plugin (most common)
export function apply(ctx: Context) {
console.log('plugin loaded')
}
// Form 2: object plugin
export const myPlugin = {
name: 'my-plugin',
apply(ctx: Context) { /* ... */ },
}
// Form 3: Service subclass (use when exposing a named service)
export class MyService extends Service {
constructor(ctx: Context) {
super(ctx, 'myService') // registers as ctx.myService
}
}
Fiber: The Plugin Instance State Machine
Every loaded plugin instance has a corresponding Fiber. The Fiber is Cordis's runtime handle for tracking that plugin's lifecycle.
Source (vendor/cordis/src/fiber.ts) defines six states:
export const enum FiberState {
PENDING, // waiting for required services to become available
LOADING, // apply() is currently executing
ACTIVE, // loaded and providing
FAILED, // apply() or config validation threw an error
UNLOADING, // disposers are running
DISPOSED, // fully unloaded, cannot restart
}
State transition diagram:
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
↘ FAILED
PENDING is the key state: when a plugin declares inject: ['tools'] and the tools service isn't loaded yet, the plugin sits in PENDING — doing nothing, not erroring, not starting. When tools becomes available, it automatically transitions to LOADING.
The design benefit: plugins don't care about load order. List plugins in any order in cordis.yml; Cordis resolves the order from dependency declarations.
III. Effect: Reversible Registration
This is the most important — and most overlooked — design in Cordis.
The Problem: Who Cleans Up?
Traditional event listeners and resource registrations require manual cleanup. Forget it and you get memory leaks:
// Traditional approach — easy to forget cleanup
emitter.on('data', handler)
// On plugin unload, you need to remember: emitter.off('data', handler)
// Forget it → memory leak + ghost listeners
Cordis's solution: every registration is an Effect with a corresponding disposer. When a plugin unloads, all its disposers run automatically.
ctx.effect(): Wrapping Reversible Operations
The core invariant in vendor/cordis/src/fiber.ts: disposers run in reverse registration order when the plugin unloads.
export function apply(ctx: Context) {
ctx.effect(() => {
// Acquire a resource
const timer = setInterval(() => console.log('heartbeat'), 1000)
// Return the disposer — Cordis calls this automatically on unload
return () => {
clearInterval(timer)
console.log('timer cleaned up')
}
})
}
// When this plugin unloads (hot reload / config change / process exit),
// clearInterval is called automatically. No manual management needed.
Built-in Effects
Most of the time you don't need to write ctx.effect() directly, because Cordis's built-in APIs already carry their own Effects:
export function apply(ctx: Context) {
// ✅ ctx.on() is already an Effect:
// listener is removed automatically when the plugin unloads
ctx.on('tools/result', (exec, result) => { /* ... */ })
// ✅ ctx.plugin() is already an Effect:
// child plugin unloads with the parent
ctx.plugin(ChildPlugin)
// ✅ ctx.tools.register() is already an Effect:
// the tool's disposer is attached to the current plugin,
// so it's unregistered automatically on unload
ctx.tools.register(myTool)
// ⚠️ Only resources Cordis doesn't manage need manual wrapping:
ctx.effect(() => {
const conn = createDatabaseConnection()
return () => conn.close()
})
}
Why This Design Matters
This is what makes Hot Module Replacement (HMR) possible in dsh. When you update a plugin's configuration:
- The old version unloads (all its Effect disposers run automatically)
- The new version loads with the updated config
No process restart. No leftover listeners. No unclosed connections. The system stays clean across live reloads.
IV. Service: Dependency Inversion in Practice
Providing a Service
To create a service, extend the Service class and pass a name to super():
import { Service, type Context } from '@deepseek-ai/cordis'
// TypeScript declaration merging: gives ctx.greeter the right type
declare module '@deepseek-ai/cordis' {
interface Context {
greeter: GreeterService
}
}
export class GreeterService extends Service {
constructor(ctx: Context) {
// Register as ctx.greeter.
// This call does two things:
// 1. Registers `this` in the service registry under key 'greeter'
// 2. Wraps the registration as an Effect — auto-removed on unload
super(ctx, 'greeter')
}
greet(who: string) {
return `Hello, ${who}!`
}
}
The core line in vendor/cordis/src/service.ts:
constructor(protected ctx: Context, name: string) {
// ...
// Register this instance in the reflection layer.
// This is an Effect — automatically revoked when the owning fiber unloads.
self.ctx.reflect.provide(name, self, this[symbols.check])
return self
}
Consuming a Service
Consumers declare inject. They don't import the provider, don't care which package provides it:
export const name = 'my-plugin'
export const inject = ['greeter'] // declare the dependency
export function apply(ctx: Context) {
// ctx.greeter is guaranteed to be available here
console.log(ctx.greeter.greet('dsh'))
}
Live Dependency Tracking: Not Just a Startup Check
inject isn't a one-time startup check. If the greeter service unloads at runtime (for example, a live config change replaces its provider), every plugin that depends on it automatically unloads, then automatically reloads when the service is back.
This is what lets dsh swap an entire capability with one config line — switch the local shell provider to a remote sandbox:
# Swap the shell provider.
# All plugins with inject: ['shell'] (including the bash tools)
# will automatically restart using the new remote implementation.
# Zero changes to consumer code.
- id: shell-provider
name: '@deepseek-ai/dsh-sandbox-remote' # change this one line
V. Events: Five Dispatch Modes, Each with Its Purpose
The Cordis event system isn't a single emit. It has five dispatch modes for five different interaction patterns.
Declaring Event Types
Use TypeScript declaration merging (interface Events) to register event types:
declare module '@deepseek-ai/cordis' {
interface Events {
// event name + parameter types + return type
'tool/executed'(name: string, duration: number): void
}
}
This is purely a compile-time TypeScript declaration — no runtime code is generated. But it makes ctx.emit, ctx.on, and all dispatch methods fully typed.
The Five Dispatch Modes
The source (vendor/cordis/src/events.ts) shows each mode's behavior clearly:
// Mode 1: emit — broadcast, no await, no return value
// Source: dispatch('emit', args).map(cb => cb(...args))
// Use for: notifications where listeners are pure observers
ctx.emit('tool/executed', 'search', 120)
// Mode 2: parallel — run all listeners concurrently, await all
// Source: await Promise.allSettled(listeners.map(cb => cb(...args)))
// Use for: async operations where order doesn't matter
await ctx.parallel('session/sync', sessionId)
// Mode 3: serial — run in order, await each, first bail value wins
// Source: for (const cb of listeners) { result = await cb(); if (isBailed(result)) return result }
// Use for: finding the first plugin willing to handle a request
const handler = await ctx.serial('approval/request', toolCall)
// Mode 4: bail — synchronous version of serial
// Use for: synchronous policy selection
const result = ctx.bail('format/render', content)
// Mode 5: waterfall — around-middleware (the most important one)
// Covered in detail below
Waterfall: dsh's Core Interception Mechanism
Waterfall is the key to understanding how plugins intercept and rewrite behavior in dsh.
It works like an onion model (similar to Koa middleware): each listener wraps all subsequent listeners. It can pass through by calling next(), or short-circuit by returning without calling next().
// Declare a waterfall event — last parameter is the next() continuation
declare module '@deepseek-ai/cordis' {
interface Events {
'agent/pre-step'(messages: Message[], next: () => Promise<Decision>): Promise<Decision>
}
}
// Plugin A: security check — rejects if sensitive content detected
ctx.on('agent/pre-step', async (messages, next) => {
if (containsSensitiveContent(messages)) {
return { type: 'reject', reason: 'contains sensitive content' }
// ↑ No next() call — downstream listeners never run
}
return next() // pass through to downstream listeners
})
// Plugin B: logging — only observes, never blocks
ctx.on('agent/pre-step', async (messages, next) => {
console.log(`[pre-step] processing ${messages.length} messages`)
const result = await next() // MUST call next(), or downstream is silently cut off
console.log(`[pre-step] result: ${result.type}`)
return result
})
The iron rule: a waterfall listener that only observes or annotates must call next(). Not calling it means "I own this decision; nothing downstream runs." Forgetting next() silently kills all downstream behavior — this is the most common mistake highlighted in the Cordis docs.
dsh uses waterfall extensively for pluggable policy:
-
agent/pre-step: decides whether to accept user input -
agent/request: can replace the model call configuration (route to a different model) -
tools/pre-execute: permission check before tool execution -
approval/request: the approval policy itself
VI. Profile + Bundle: Composition as Product
With all five mechanisms in mind, dsh's Profile/Bundle system becomes straightforward.
Bundle: a plugin configuration list (a YAML file) that says "mount these plugins with these settings":
# Simplified view of the dsh-base bundle
# (real file: packages/bundle/base/cordis.patch.yml)
- insert:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek' # model adapter plugin
- id: tools
name: '@deepseek-ai/dsh-tools' # tool registry plugin
- id: session-persistence
name: '@deepseek-ai/dsh-session-persistence-jsonl' # session persistence plugin
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local' # local sandbox plugin
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop' # agent loop plugin
Profile: an ordered stack of bundles, where later layers can override earlier ones:
web profile = dsh-base + dsh-web-app + user's cordis.patch.yml
↑ user can override any row here
Inspect the actual plugin tree loaded for a profile:
dsh --profile web --dump-config
# Prints the full configuration list for every plugin in the current profile.
# Every row is a candidate for override in your cordis.patch.yml.
VII. Complete Example: Writing a dsh Plugin
Putting all five concepts together in one complete tool plugin:
// my-tool-plugin.ts
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-tool-plugin'
// Declare dependencies: plugin stays PENDING until both services are ready
export const inject = ['tools', 'systemPrompt']
export function apply(ctx: Context) {
// ctx.tools.register() is an Effect:
// the tool is automatically unregistered when this plugin unloads
ctx.tools.register(defineTool({
name: 'get_time',
description: 'Get the current date and time.',
parameters: {},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute() {
return new Date().toLocaleString('en-US')
},
}))
// ctx.on() is an Effect: listener is removed automatically on plugin unload
ctx.on('tools/result', (exec, result) => {
if (exec.name === 'get_time') {
console.log(`[log] get_time called, returned: ${JSON.stringify(result.content)}`)
}
})
}
Add it to dsh via cordis.patch.yml:
- insert:
- id: my-tool
name: './my-tool-plugin.ts'
That's the complete dsh plugin development loop. No framework fork, no internal code changes. Mount a plugin and the capability is in. Unload the plugin and everything returns to its original state.
Design Philosophy Summary
Each of the five Cordis mechanisms solves a specific problem:
| Mechanism | Problem It Solves |
|---|---|
| Context (proxy) | Consumers don't depend on specific implementations; providers can be swapped freely |
| Fiber (state machine) | Plugins load in dependency order; PENDING replaces startup errors |
| Effect (reversible registration) | Hot reload and cleanup require no manual management; resource leaks are structurally prevented |
| Service (dependency injection) | Dependencies declared via inject; tracking is live, providers can be replaced at runtime |
| Event (five modes) | Observation / interception / policy selection / concurrent coordination — each scenario has the right mode |
These five mechanisms compose into a system where any part can be replaced while the whole keeps running. That's not an accident — it's the result of deliberate design.
Next up: Series Part 03 — The Tool System. We'll go deep into ctx.tools: registration mechanics, automatic schema generation, the three-phase execution pipeline, and how the approval gate intercepts dangerous operations.
Check out PrimeSkills — a curated marketplace of AI agents and skills validated in real-world, enterprise-grade workflows. Not demos — things that actually work in production.
Find more on my Homepage
Top comments (0)