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.
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
- Creating a local plugin project structure
- The essence of a plugin: a TypeScript module exporting an apply function
- The meaning of
export const nameandexport function apply(ctx) - Three plugin forms: function, object, class
- Registering in cordis.yml and loading
- Starting and verifying with
pnpm dsh web --patch
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:
mkdir -p scratch-plugin/src
cd scratch-plugin
pnpm init
The resulting package.json:
{
"name": "scratch-plugin",
"version": "0.1.0",
"main": "src/index.ts"
}
(2) ▶ Example 2
pnpm add -D @deepseek-ai/cordis typescript
Project structure:
scratch-plugin/
├── src/
│ └── index.ts ← Plugin main entry
├── package.json
└── node_modules/
(3) TypeScript Configuration
Create tsconfig.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:
- Export a
namestring — the plugin's unique identifier - Export an
applyfunction — the plugin entry point
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
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:
- Log prefix:
[my-plugin] loaded! - Configuration namespace:
plugins.my-plugin.config - Dependency declaration: other plugins reference by name
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)
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
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
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
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
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
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:
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
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:
- Path resolution is not affected by the working directory
- Consistent behavior across different startup methods
- Clear and unambiguous during debugging
(3) Starting and Loading
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:
[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:
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:
plugins:
greeter:
$insert: /home/alice/projects/scratch-plugin
Start and verify:
pnpm dsh web --patch
# [greeter] greeter plugin loaded
# [greeter] session started: abc-123
❓ FAQ
my-plugin is a valid name. We recommend lowercase letters and hyphens; avoid camelCase.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.[error] plugin not found: /wrong/path/to/plugin.typescript export const name = 'my-plugin' export const Config = Schema.object({ ... }) export function apply(ctx: Context) { ... } 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
- A plugin is a TypeScript module exporting
name+apply(ctx); the framework calls apply when loading - Through
ctx, register timers, event listeners, commands, etc.; all are automatically reclaimed on unload - Three plugin forms: function (simplest), object (standard), class (complex/needs inheritance)
- Use
$insert+ absolute path incordis.ymlto register local plugins pnpm dsh web --patchstarts and loads overlay configuration- name must be globally unique; duplicate names cause loading failures
📝 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?