DeepSeek Harness: First Plugin

Last updated: 2026-08-31

Writing your first plugin is the key step to deeply understanding DSH — leaping from "using the framework" to "extending the framework." This lesson starts from creating a local project and progressively completes a loadable, runnable Cordis plugin.

💡 Tip: The core protocol of a DSH plugin is minimal — just export name and apply. When the framework calls apply(ctx), the plugin registers capabilities through ctx; when the plugin is unloaded, resources registered on ctx are automatically reclaimed.

📋 Prerequisites: Completed 08-community-plugins.md, familiar with the plugin ecosystem overview

1. What You'll Learn


Plugin Structure

2. Creating a Local Project

(1) Initializing the Project Directory

Every DSH plugin is essentially a Node.js package. Let's set one up from scratch:

BASH
mkdir -p scratch-plugin/src
cd scratch-plugin
pnpm init

The resulting package.json:

JSON
{
  "name": "scratch-plugin",
  "version": "0.1.0",
  "main": "src/index.ts"
}

(2) ▶ Example 2

BASH
pnpm add -D @deepseek-ai/cordis typescript

Project structure:

TEXT 📖 Display only
scratch-plugin/
├── src/
│   └── index.ts      ← Plugin main entry
├── package.json
└── node_modules/

(3) TypeScript Configuration

Create tsconfig.json:

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

3. Plugin Core Protocol

(1) Minimal Plugin

A DSH plugin only needs to satisfy two conditions:

  1. Export a name string — the plugin's unique identifier
  2. Export an apply function — the plugin entry point
TYPESCRIPT
import { Context } from '@deepseek-ai/cordis'

export const name = 'my-plugin'

export function apply(ctx: Context) {
  ctx.logger.info('my-plugin loaded!')
}

This is a complete plugin. After the framework loads it, it calls apply(ctx), and ctx.logger.info() outputs a log.

(2) ▶ Example 2

100%
graph LR
    LOAD[Framework Loads Plugin] --> CALL[Call apply<br/>ctx is the plugin's "world"]
    CALL --> RUN[Plugin Running]
    UNLOAD[Plugin Unload] --> CLEAN[Resources registered on ctx<br/>automatically reclaimed]

apply is called only once when the plugin loads. If the plugin needs to run continuously, register timers, listeners, etc. inside apply.

(3) Purpose of name

name is the plugin's identity, used for:

TYPESCRIPT
export const name = 'my-plugin'

⚠️ name must be globally unique; duplicating an existing plugin's name will cause loading to fail.


4. Three Plugin Forms

Cordis supports three plugin writing styles. They are functionally equivalent; choose based on complexity:

(1) Function Form (Simplest)

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

export const name = 'hello-fn'

export function apply(ctx: Context) {
  ctx.logger.info('hello from function plugin')
}

Use case: Simple tools, one-time registration.

(2) Object Form

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

export default {
  name: 'hello-obj',
  apply(ctx: Context) {
    ctx.logger.info('hello from object plugin')
  }
}

Use case: Medium-complexity plugins that need to export multiple fields (e.g., Config, inject).

(3) Class Form

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

export default class HelloClass {
  static name = 'hello-class'

  constructor(private ctx: Context) {
    ctx.logger.info('hello from class plugin')
  }
}

Use case: Complex plugins that need internal state management or implement service base classes.

(4) Three Forms Comparison

Dimension Function Object Class
Complexity Low Medium High
State management Closures Closures Instance properties
Export Config Separate export Object field Static property
Inheritance Not supported Not supported Supported
Best for Tool plugins Standard plugins Service plugins

5. Making the Plugin "Do Something"

(1) Registering a Periodic Log

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

export const name = 'heartbeat'

export function apply(ctx: Context) {
  ctx.setInterval(() => {
    ctx.logger.info('heartbeat tick')
  }, 60000)
}

Timers registered with ctx.setInterval are automatically cleared when the plugin unloads — this is the core advantage of Cordis auto-cleanup.

(2) Listening to Events

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

export const name = 'welcome'

export function apply(ctx: Context) {
  ctx.on('session/created', (session) => {
    ctx.logger.info(`new session: ${session.id}`)
  })
}

Listeners registered with ctx.on are also automatically removed on unload.

(3) Registering Commands

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

export const name = 'hello-cmd'

export function apply(ctx: Context) {
  ctx.command('hello <name:text>')
    .action(({ session }, name) => {
      return `Hello, ${name}!`
    })
}

6. Registering in cordis.yml and Loading

(1) cordis.yml Configuration

Register the local plugin in cordis.yml at the DSH project root:

YAML
plugins:
  my-plugin:
    $insert: /absolute/path/to/scratch-plugin

$insert injects a local plugin into the plugin list. The path must be absolute.

(2) Absolute vs. Relative Paths

YAML
plugins:
  my-plugin:
    $insert: /home/alice/plugins/scratch-plugin   # ✅ Absolute path
    # $insert: ./scratch-plugin                    # ⚠️ Relative path works but not recommended

Reasons to prefer absolute paths:

(3) Starting and Loading

BASH
pnpm dsh web --patch

The --patch parameter tells DSH to read $insert and other override operations from cordis.yml, layering local plugins on top of the default configuration.

(4) Verifying the Load

After starting, check the terminal log:

TEXT 📖 Display only
[my-plugin] loaded!

Or search for my-plugin in the Web UI's plugin list.


▶ Example 7: Greeter Plugin

Combine the knowledge above into a complete example:

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

export const name = 'greeter'

export function apply(ctx: Context) {
  ctx.logger.info('greeter plugin loaded')

  ctx.on('session/created', (session) => {
    ctx.logger.info(`session started: ${session.id}`)
  })

  ctx.setInterval(() => {
    ctx.logger.info('greeter heartbeat')
  }, 300000)
}

cordis.yml configuration:

YAML
plugins:
  greeter:
    $insert: /home/alice/projects/scratch-plugin

Start and verify:

BASH
pnpm dsh web --patch
# [greeter] greeter plugin loaded
# [greeter] session started: abc-123

❓ FAQ

Q Can the plugin name contain hyphens?
A Yes, my-plugin is a valid name. We recommend lowercase letters and hyphens; avoid camelCase.
Q Can the apply function be async?
A Yes. async function apply(ctx) is perfectly valid; the framework will await the async apply. Note: until the async apply completes, the plugin is in a pending state and plugins depending on it won't be loaded.
Q What happens if the $insert path is wrong?
A DSH will report an error and skip the plugin at startup; it won't crash the whole application. The terminal will show something like [error] plugin not found: /wrong/path/to/plugin.
Q How do I export Config from a function-form plugin?
A Export it separately: typescript export const name = 'my-plugin' export const Config = Schema.object({ ... }) export function apply(ctx: Context) { ... }
Q Can the same plugin be loaded multiple times?
A Not by default — name is globally unique. If you need multiple instances, use isolate configuration to create isolated scopes (see 20-scope.md).
Q Do I need to restart every time I change code during local development?
A Yes, pnpm dsh web --patch doesn't support hot reload. During development, you can use --dump-config to verify configuration, or see 18-hot-reload.md for HMR mechanisms.

📖 Summary


📝 Exercises

1. ⭐ Basic: Follow this lesson's steps to create a function-form hello-world plugin that outputs the log "hello world!" in apply, register it in cordis.yml, and verify by starting.

2. ⭐⭐ Intermediate: Rewrite the hello-world plugin in both object and class forms, load each separately, and confirm all three produce the same output.

3. ⭐⭐⭐ Challenge: Write an uptime plugin that records the plugin load time and outputs "Running for N minutes" every minute via ctx.setInterval. Think: should the timer reset if the plugin is unloaded and reloaded? Why?

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏