跪拜 Guibai
← Back to the summary

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.png

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/result is emitted as part of result materialization, before execute'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.