DEV Community

Cover image for DeepSeek Harness Series (02): Everything Is a Plugin — A Deep Dive into Cordis Core Design
WonderLab
WonderLab

Posted on

DeepSeek Harness Series (02): Everything Is a Plugin — A Deep Dive into Cordis Core Design

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()
Enter fullscreen mode Exit fullscreen mode

This works, but creates four problems:

  1. Swapping a component is painful: replacing LLMAdapter with another provider means hunting down every import statement
  2. Load order is manually enforced: AgentLoop can only be created after the other three are initialized — you have to get that right by hand
  3. No hot reload: changing one config option means restarting the whole process
  4. Cleanup is a nightmare: session needs to close its connection, loop needs 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()
Enter fullscreen mode Exit fullscreen mode

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
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

State transition diagram:

PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                  ↘ FAILED
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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()
  })
}
Enter fullscreen mode Exit fullscreen mode

Why This Design Matters

This is what makes Hot Module Replacement (HMR) possible in dsh. When you update a plugin's configuration:

  1. The old version unloads (all its Effect disposers run automatically)
  2. 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}!`
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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'))
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)}`)
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Add it to dsh via cordis.patch.yml:

- insert:
  - id: my-tool
    name: './my-tool-plugin.ts'
Enter fullscreen mode Exit fullscreen mode

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)