跪拜 Guibai
← Back to the summary

Cordis Context and Plugins: The Container Pattern Behind Agent Frameworks

DeepSeek Harness from Scratch: 01 Getting Started with Cordis Core Concepts

This series builds a simplified version of DeepSeek Harness (loop, session, tool, system prompt, etc.) from scratch using the Cordis framework. This is the first article: before writing any Agent logic, first understand the two core concepts of Cordis.

Why Cordis?

The Agent framework we want to implement is naturally modular: Session management, Tools system, System Prompt assembly, LLM calls, Agent Loop... These modules need to depend on each other, share state, respond to events, and support plugin-based extension.

Cordis is a framework designed for this kind of scenario (it is also the underlying framework of Koishi). Its core abstractions are only two:

Concept One-sentence understanding
Context A container that holds services and manages plugin lifecycles
Plugin A functional unit that puts things into the container, with three forms

This article explains these two concepts thoroughly through a runnable example.

Preparation

# Initialize package.json
pnpm init

# Install Cordis framework core package (runtime dependency)
pnpm add @cordisjs/core

# Install dev dependencies: tsx to run TS, typescript compiler, node type definitions
pnpm add -D tsx typescript @types/node

Add a startup script in package.json:

{
  "type": "module",                  // ES Module syntax (supports import / export)
  "scripts": {
    "dev": "tsx src/main.ts"         // Use tsx to directly run the TypeScript entry
  },
  "dependencies": {
    "@cordisjs/core": "^4.0.0-beta.5" // Cordis framework core package
  }
}

Part 1: Hello World — Getting to Know Context

First, let's look at the project directory structure:

blog-01-core-concepts/
├── package.json          # Project config: dependencies, startup scripts
├── package-lock.json     # Dependency lock file (auto-generated by pnpm)
├── tsconfig.json         # TypeScript compilation config
├── README.md             # Project description
├── blog.md               # This document
└── src/
    └── main.ts           # Code entry, run by pnpm dev

All our code is written in src/main.ts, and pnpm dev will run it directly using tsx.

Let's look at the simplest Cordis program:

import { Context } from '@cordisjs/core'

// A plugin is a function that receives a Context parameter
function helloPlugin(ctx: Context) {
  console.log('🎉 Hello from plugin!')

  // Register a service to the Context (using ctx.provide())
  // This allows other plugins to access it via ctx.hello
  ctx.provide('hello', {
    // Exposed method: say hello
    say(name: string) {
      return `Hello, ${name}!`
    }
  })
}

// 1️⃣ Create Context
const ctx = new Context()

// 2️⃣ Register plugin (note: ctx.plugin() is async, requires await)
await ctx.plugin(helloPlugin)

// 3️⃣ Use the service
console.log(ctx.hello.say('World'))  // Hello, World!

Run output:

1️⃣  Creating Context...
2️⃣  Registering plugin...
🎉 Hello from plugin!
3️⃣  Using service...
Hello, World!

Key Points

Context is a container. It can hold various services; plugins can add services to it; other plugins can retrieve services from it. This is the basic interaction pattern of the Cordis world:

Plugin A ──ctx.provide('hello', ...)──▶ Context ◀──ctx.hello.say(...)── Plugin B

ctx.provide(name, value) mounts a service called name onto the Context, after which any code that gets this Context can access it via ctx[name]. The service can be a plain object, or a class instance as we will discuss later.

ctx.plugin() is asynchronous. Don't forget to await when registering a plugin, otherwise the plugin might not be activated yet when you start using it.

Part 2: The Three Forms of Plugins

The essence of a plugin is "something that receives a Context and does something", but Cordis allows three ways of writing them, each suitable for different scenarios.

Form 1: Function Plugin

// Form 1: Function plugin (the method from Part 1)
function loggerPlugin(ctx: Context) {
  console.log('✅ Function plugin logger activated')

  // Internal state of the plugin (private data, not directly accessible from outside)
  const logs: string[] = []

  // Register service: 'logger' is the service name (custom), will be mounted to ctx.logger
  // Function plugins have no name property, so the service name is entirely determined by the first argument of provide()
  ctx.provide('logger', {
    // Exposed method: record a log
    log(message: string) {
      const entry = `[${new Date().toLocaleTimeString()}] ${message}`
      logs.push(entry)
      console.log(entry)
    },
    // Exposed method: get all logs
    getLogs() {
      return [...logs]
    }
  })
}

The simplest and most direct, suitable for plugins with uncomplicated logic. Note that the logs array is private state inside a closure—external code can only access it indirectly through the methods exposed by ctx.logger, which is natural encapsulation.

Form 2: Object Plugin

// Form 2: Object plugin (can carry meta-information like name)
const counterPlugin = {
  // The plugin's name, used for logging, debugging, dependency declarations, and other meta-information
  name: 'counter',
  // Core logic written in the apply method
  apply(ctx: Context) {
    console.log('✅ Object plugin counter activated')

    // Internal state of the plugin
    let count = 0

    // Register service: 'counter' is the service name (custom), will be mounted to ctx.counter
    ctx.provide('counter', {
      // Exposed method: increment the counter and return the new value
      increment() {
        return ++count
      },
      // Exposed method: get the current count
      getCount() {
        return count
      }
    })
  }
}

Object plugins have meta-information like name that function plugins lack. The core logic is written in the apply method. Suitable for scenarios where you need to leave a name in the plugin registry (for logging, debugging, dependency declarations).

Form 3: Class Plugin

// Form 3: Class plugin (suitable for plugins with internal state)
class TokenBucket {
  private tokens: number

  // Constructor receives Context and config, config comes from the second argument of ctx.plugin()
  constructor(ctx: Context, config: { capacity: number }) {
    console.log(`✅ Class plugin tokenBucket activated (capacity: ${config.capacity})`)
    this.tokens = config.capacity

    // Register service: 'tokenBucket' is the service name (custom), will be mounted to ctx.tokenBucket
    ctx.provide('tokenBucket', {
      // Exposed method: try to take a token, returns true on success, false if insufficient tokens
      take: () => this.take(),
      // Exposed method: return the number of remaining tokens
      remaining: () => this.tokens
    })
  }

  private take(): boolean {
    if (this.tokens <= 0) return false
    this.tokens--
    return true
  }
}

Class plugins have two unique advantages:

  1. The constructor can receive configuration: The second argument of ctx.plugin(TokenBucket, { capacity: 3 }) is passed to the constructor;
  2. Suitable for plugins with complex internal state: Stateful objects like token buckets are most naturally expressed using classes.

Assembly and Execution

// Create Context
const ctx = new Context()

// Register the three forms of plugins
await ctx.plugin(loggerPlugin)
await ctx.plugin(counterPlugin)
// The second argument is the plugin's configuration, passed to the constructor
await ctx.plugin(TokenBucket, { capacity: 3 })

// Use services: call logger's log method (service name and method are both custom)
ctx.logger.log('This is a log entry')

// Call counter's increment method (counter +1 each call)
console.log('Counter:', ctx.counter.increment())  // 1
console.log('Counter:', ctx.counter.increment())  // 2

// Call tokenBucket's take and remaining methods
console.log('Take token:', ctx.tokenBucket.take())     // true
console.log('Take token:', ctx.tokenBucket.take())     // true
console.log('Take token:', ctx.tokenBucket.take())     // true
console.log('Take token:', ctx.tokenBucket.take())     // false, all taken
console.log('Remaining tokens:', ctx.tokenBucket.remaining())  // 0

Run output:

📦 Registering three forms of plugins...
✅ Function plugin logger activated
✅ Object plugin counter activated
✅ Class plugin tokenBucket activated (capacity: 3)

🎯 Using services...
[7:30:09 PM] This is a log entry
Counter: 1
Counter: 2
Take token: true
Take token: true
Take token: true
Take token: false
Remaining tokens: 0

How to Choose Among the Three Forms?

Form Meta-info Config Complex State Suitable Scenario
Function Plugin Average (closure) Simple logic, rapid prototyping
Object Plugin Average (closure) Plugins needing a name/meta-info
Class Plugin Excellent Stateful plugins needing configuration

All three forms lead to the same destination: they all mount services onto the Context via ctx.provide(). Choosing one is just a matter of code organization.

Summary

In this article, we established the mental model of Cordis:

  1. Context is a container: ctx.provide() stores services, ctx.xxx retrieves services, plugins collaborate through it;
  2. Plugins have three forms: function, object, class, choose as needed, all ultimately provide services to the Context.

These two pieces seem simple, but combined they form the foundation of the entire Agent framework—the Session, Tools, and Agent Loop we will implement later will each be services in the Context, and each will use a plugin form.