A Step-by-Step Build of a DeepSeek Harness Plugin, from Tool Definition to HMR Debugging
Chapter 10: Writing a dsh Plugin from Scratch — A Practical Guide
Series: DeepSeek Harness Source Code in Practice
Original Repository: https://github.com/deepseek-ai/deepseek-harness
Now that you understand Cordis principles, it's time to get hands-on. This chapter walks you through writing a complete dsh plugin from scratch: defining a tool, adding configuration, registering event listeners, packaging it into a bundle (plugin package), installing it into a profile (configuration file), and debugging with HMR (Hot Module Replacement).
This is not a conceptual demo — every step includes real code and commands. Follow along and it will run.
I'm Pa Lang Mao, and this is Chapter 10 of the series. Let's jump straight into the code.
10.1 Simplest Tool Plugin: greet
Start by writing the simplest possible tool plugin, letting the model call a greet tool to say hello.
Create the file greet-tool/src/index.ts (refer to docs/cordis-tutorial/07-into-the-harness.md and docs/user/develop/basic/tool.md):
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}
Line-by-line breakdown:
| Line | Purpose |
|---|---|
export const name = 'greet-tool' |
Display name used for diagnostics |
export const inject = ['tools'] |
Declares dependency on the tools service; loads only when ctx.tools is ready |
ctx.tools.register(...) |
Registers the tool, returns a disposer automatically bound to the current Fiber |
defineTool(...) |
Converts parameters spec to JSON Schema, infers args types, validates model parameters |
parameters.name |
Tool parameter definition: type + required + description |
output.schema |
Declares the JSON Schema of the execute return value |
output.render |
Converts the return value into content blocks visible to the model |
execute(args) |
The tool body; args have already been type-checked and inferred |
Create greet-tool/cordis.yml (local patch override):
- insert:
- id:
greet
name:
'./src/index.ts'
Load it into dsh Web with --patch:
pnpm dsh web --patch ./greet-tool/cordis.yml
Open http://127.0.0.1:3080, tell the model "Use the greet tool to greet Ada", and the model will call the greet tool and receive Hello, Ada!.
10.2 Adding Configuration to the Plugin
The tool works, but the greeting content is hardcoded. Let users customize it through configuration.
Refer to docs/user/develop/basic/config.md and docs/cordis-tutorial/05-config.md:
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export interface Config {
greeting: string
targets: string[]
}
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default('Hello'),
targets: Schema.array(String).default(['world']),
})
export function apply(ctx: Context, config: Config) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `${config.greeting}, ${args.name}!`
},
}))
}
Key changes:
| Change | Explanation |
|---|---|
export interface Config |
TypeScript type; consumers get the type |
export const Config |
Schemastery schema; Cordis gets the validator |
apply(ctx, config) |
Second parameter receives the validated configuration |
config.greeting |
Replaces the hardcoded 'Hello' |
Pass configuration in cordis.yml:
- insert:
- id:
greet
name:
'./src/index.ts'
config:
greeting:
'Hi there'
Error when configuration validation fails (docs/cordis-tutorial/05-config.md):
ValidationError: invalid config:
- $.greeting expected string but got 42
The plugin enters FAILED state, and the process terminates with exit code 1. dsh's design principle is "fail loud" — better to fail at startup than run silently with incorrect configuration.
Golden rule: Configuration is not optional; it is mandatory. dsh requires that any value two deployments might want to set differently must be a configuration field — the test is "can cordis.yml change this value without changing the code."
The !!js tag supports runtime-computed configuration values (docs/cordis-tutorial/05-config.md):
- insert:
- id:
greet
name:
'./src/index.ts'
config:
greeting:
!!js
process.env.GREETING
??
'Hello'
!!js is only available in config and disabled fields. disabled: !!js process.platform === 'win32' can gate plugins by platform.
10.3 Adding an Event Listener: Tool Call Logging
The tool is usable. Now add an independent listener plugin that logs the results of all tool calls.
Refer to the observer plugin in docs/cordis-tutorial/07-into-the-harness.md:
// tool-logger/src/index.ts
import type { Context } from '@deepseek-ai/cordis'
import '@deepseek-ai/dsh-tools' // pulls in event type declarations
export const name = 'tool-logger'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.on('tools/result', (exec, result) => {
const text = result.content
.map(block => (block.type === 'text' ? block.text : ''))
.join('')
console.log(`[tool-logger] ${exec.name} -> ${text}`)
})
}
Key points:
| Point | Explanation |
|---|---|
import '@deepseek-ai/dsh-tools' |
Pulls in declaration merge so the 'tools/result' event has types |
ctx.on('tools/result', ...) |
Registers a listener; automatically removed on unload |
exec |
Tool execution context (name, arguments, callId, etc.) |
result |
Tool execution result (content blocks) |
tools/result is an emit event — broadcast synchronously, return value ignored. The listener fires when the result materializes, before execute's Promise resolves. So the tutorial above says:
The logger fired first:
tools/resultis emitted as part of result materialization, beforeexecute's promise resolves to the caller.
Combined together:
- name:
'@deepseek-ai/dsh-system-prompt'
- name:
'@deepseek-ai/dsh-tools'
- name:
'./tool-logger/src/index.ts'
- name:
'./greet-tool/src/index.ts'
@deepseek-ai/dsh-tools depends on the systemPrompt service (the tool's schema goes into the system prompt), so dsh-system-prompt must be listed. Without it, the tools plugin will be PENDING.
Runtime output:
[tool-logger] greet -> Hello, Cordis!
tool replied: [{"type":"text","text":"Hello, Cordis!"}]
The two plugins don't know about each other — the registry service and events connect them. This is the power of loosely coupled extension.
10.4 Packaging into a Bundle
Local --patch is sufficient, but to share a plugin or install it in different environments, you need to package it into a bundle.
Refer to docs/user/develop/basic/publish.md. A bundle's structure:
greet-plugin/
├── package.json # declares dsh.bundle
├── cordis.patch.yml # configuration layer
└── index.js # plugin code
package.json:
{
"name": "dsh-greet-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"files": ["index.js", "cordis.patch.yml"],
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
dsh.bundle tells dsh this is a bundle package; patch points to the configuration layer file.
index.js:
export const name = 'greet-plugin'
export function apply() {
console.log('[greet-plugin] plugin loaded!')
}
cordis.patch.yml:
- insert:
- id:
greet
name:
dsh-greet-plugin
Note that name changed from a relative path to an npm package name — Node's resolution mechanism will find the installed package in node_modules.
10.5 Installing into a Profile
The bundle is packaged. Now install it into a profile. A profile is a runnable composition of dsh, consisting of a set of bundles.
Install command (docs/user/develop/basic/publish.md):
dsh plugin --profile demo add ./greet-plugin
First-time use initializes the profile, automatically adding @deepseek-ai/dsh-base as the first bundle. After installation, the profile's package.json looks like this:
{
"name": "dsh-profile-demo",
"private": true,
"dependencies": {
"dsh-greet-plugin": "link:/path/to/greet-plugin"
},
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"dsh-greet-plugin"
]
}
}
}
Verify configuration:
dsh --profile demo --dump-config
# Output will show the "# == dsh-greet-plugin" layer
Start:
dsh --profile demo
10.6 Configuration Layer Loading Order
Understanding the configuration layer loading order is critical (docs/user/develop/basic/publish.md):
1. Each bundle's patch (in dsh.profile.bundles list order)
@deepseek-ai/dsh-base first, then each installed bundle
2. The profile's own cordis.patch.yml
(user-level configuration)
3. $DSH_HOME/cordis.patch.yml
(machine-level preferences, shared by all profiles)
4. Each --patch <path> overlay (in command-line order)
(temporary overlays)
Key rule: Later layers override earlier layers, matching rows by id, replacing the entire config value — not deep merging.
Example of two-layer overlay:
| Layer | id | config |
|---|---|---|
| bundle layer | greet | { greeting: 'Hello' } |
| profile layer | greet | { greeting: 'Hi' } |
| Final result | greet | { greeting: 'Hi' } (entire config replaced) |
Note: the profile layer replaced the entire config, not just changing greeting to Hi while keeping other fields. If your bundle layer config has multiple fields, the profile layer must rewrite all of them to change one.
Golden rule: patch is not deep merge; it is whole-row replacement. To change one field, rewrite the entire config row — this is dsh's design choice: explicit over implicit.
10.7 Build Pitfall When Installing from GitHub
Before publishing to npm, you might want to install directly from GitHub. There is an important pitfall (docs/user/develop/basic/publish.md):
dsh plugin --profile demo add github:you/greet-plugin
Git install pulls source code, not build artifacts. pnpm does not automatically run build scripts. A TypeScript package without a lib/ directory won't run.
Both sides need one step:
Plugin author: add a prepare script in package.json:
{
"scripts": {
"prepare": "tsdown src/index.ts --format esm --dts"
}
}
pnpm runs prepare after git install. It must be self-contained — cannot assume a monorepo context.
Plugin user: pnpm >=10 defaults to refusing to run prepare scripts for git dependencies. You need to allow it in the profile's pnpm-workspace.yaml:
allowBuilds:
dsh-greet-plugin:
true
The tutorial explicitly warns:
Treat that allowance as what it is: permission to execute the package's code on your machine at install time, outside any sandbox the agent runs under.
Don't want users to deal with this? Publish to npm or distribute a tarball:
# Publish to npm (users install pre-built code directly)
npm publish
# Or distribute a tarball
pnpm pack
# User runs: dsh plugin --profile demo add ./greet-plugin-0.1.0.tgz
10.8 HMR Debugging Loop
During development, you don't need to restart every time you change code. HMR lets you save a file and hot-replace it.
Complete development loop:
| Step | Command/Action |
|---|---|
| 1. Start dsh with patch | pnpm dsh web --patch ./greet-tool/cordis.yml |
| 2. Edit code | Edit greet-tool/src/index.ts |
| 3. Save | HMR automatically unloads old instance, loads new code |
| 4. Verify | Test in browser |
| 5. Edit config | Edit cordis.yml; HMR detects this too |
| 6. Diagnose PENDING | If plugin doesn't load, check whether injected services are available |
HMR prerequisites (docs/cordis-tutorial/06-composition-and-hmr.md):
| Condition | Explanation |
|---|---|
| Explicit id | Entries without explicit ids generate a new id on each read, misidentified as delete+add |
| Dependent services present | HMR itself injects timer and logger; without them HMR will be PENDING |
| tsx runtime | node --import tsx lets TypeScript run directly |
10.9 Complete Plugin Checklist
When writing a dsh plugin, check these items:
| Check Item | Passing Standard |
|---|---|
| Export name | Display name for diagnostics |
| Export inject | Declares all dependent services |
| Export Config (if configurable) | Schemastery schema, not a plain object |
| apply(ctx, config) | Second parameter receives validated configuration |
| Registration via effect API | ctx.on / ctx.tools.register / ctx.plugin |
| Resources wrapped with ctx.effect | Timer, connection, watcher, and other non-Cordis-managed resources |
| Service names prefixed | Avoid conflicts with dsh built-in service names |
| Declaration merge with types | declare module '@deepseek-ai/cordis' |
| No hardcoded tunable values | All tunable values are Config fields |
| Test coverage | Follow docs/testing.md strategy |
Golden rule: Writing a plugin is not hard; writing a correct plugin is. The checklist is not a formality — it is the distillation of hard-won lessons.
Chapter Summary
| Step | Key Point |
|---|---|
| Write a tool | defineTool + inject + apply; register via ctx.tools.register |
| Add configuration | Export Config schema (Schemastery); apply's second parameter |
| Add a listener | ctx.on('tools/result', …) + import type declarations |
| Package a bundle | package.json declares dsh.bundle + cordis.patch.yml |
| Install to profile | dsh plugin --profile add |
| Config layer order | bundle → profile → home → --patch; whole-row replacement, not deep merge |
| GitHub install | Requires prepare script + allowBuilds permission |
| HMR debugging | Explicit id + dependent services present + tsx runtime |
I'm Pa Lang Mao. Chapter 10 is complete. From zero to an installable plugin package, every step has code and commands. Follow along and it will run.
Questions? Discuss in the comments. Corrections welcome. If this helped you, share it with colleagues also writing dsh plugins.
Next chapter we dive deep into Cordis's event system — how to use the five keys, and when to use which.