Skip to main content
All posts
Development

Build a DeepSeek-Harness Plugin from Scratch: A Step-by-Step Tutorial

A hands-on tutorial that builds a DeepSeek-Harness plugin: package.json, cordis.patch.yml, a registered tool, and installing it into a profile with dsh plugin add.

Building a DeepSeek-Harness (dsh) plugin means writing a small npm package: a package.json with a dsh.bundle.patch field, a cordis.patch.yml that tells dsh what to load, and a JS/TS module that exports apply(ctx). This tutorial builds one from an empty folder to a working tool you can install into a real profile — no scaffolding CLI required, because dsh doesn't ship one.

What you need before you start

You need a working dsh install — see DeepSeek Harness Quickstart or the platform install guide if you haven't set one up — plus Node.js and pnpm on your PATH, since dsh plugin forwards directly to pnpm. If you only want the theory of what a plugin is before touching code, read How DeepSeek-Harness Plugins Work Under the Hood first; this post assumes that contract and focuses on the concrete steps.

Step 1: Create the folder

mkdir hello-plugin && cd hello-plugin
npm init -y

The folder is both your working directory and, eventually, your npm package root.

Step 2: Write package.json

The one field that turns a plain npm package into something dsh recognizes as an installable plugin is dsh.bundle.patch:

{
  "name": "dsh-hello-plugin",
  "version": "0.1.0",
  "type": "module",
  "main": "index.js",
  "files": ["index.js", "cordis.patch.yml"],
  "dsh": {
    "bundle": { "patch": "./cordis.patch.yml" }
  }
}

A few fields worth calling out:

  • "type": "module" — write ESM, matching the official examples throughout the docs.
  • "files" — the allowlist of what actually gets published to npm; leaving out cordis.patch.yml here is a common mistake that silently breaks installs from the registry.
  • "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } — the only field dsh actually reads to decide "this package is a bundle." Without it, dsh plugin add still installs the package as a normal dependency, but prints a warning and activates nothing.

Step 3: Write cordis.patch.yml

This is the file the dsh.bundle.patch field points at. It's a YAML array; each entry inserts one or more rows into the running Cordis plugin tree, with an id, a name (a module specifier or a path to your entry file), and optional config:

- insert:
    - id: hello
      name: './index.js'

id is how later layers (a profile's own patch, a machine-level patch, a --patch flag) can target and override this exact row — see cordis.patch.yml Explained if you want the full mechanics of how insert and override-by-id interact across layers.

Step 4: Write the entry point

index.js exports the plugin's apply(ctx) function — the one required piece of the contract:

export const name = 'hello-plugin'

export function apply(ctx) {
  console.log('hello-plugin loaded')
}

That already is a valid, loadable plugin. It doesn't do anything useful yet, but it satisfies the contract dsh actually checks: a module exporting apply.

Step 5: Register a tool

To make the plugin do something an agent can call, register a tool with defineTool from @deepseek-ai/dsh-tools, and declare that you depend on the tools service with inject:

import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'hello-plugin'
export const inject = ['tools']

export function apply(ctx) {
  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}!`
    },
  }))
}

inject: ['tools'] matters mechanically, not just semantically: dsh won't call apply until the tools service exists, and will unload the plugin automatically if that service ever disappears. For the full breakdown of every field in defineToolparameters, output.schema vs output.render, and how execute fits in — see How to Add a Custom Tool to DeepSeek-Harness with defineTool.

Step 6: Load it locally, without installing anything

You don't need to publish or dsh plugin add a plugin to try it — point a running profile at the file directly with a --patch flag and an insert entry using an absolute path:

# scratch.cordis.yml
- insert:
    - id: hello
      name: '/absolute/path/to/hello-plugin/index.js'
dsh --profile web --patch ./scratch.cordis.yml

To confirm dsh actually picked it up without starting a full session, use --dump-config, which prints the fully composed config tree and exits:

dsh --profile web --patch ./scratch.cordis.yml --dump-config

Step 7: Iterate with hot reload

Editing index.js while dsh is running won't pick up changes on its own — that requires @deepseek-ai/cordis-plugin-hmr in the loaded plugin tree. With HMR active, saving the file triggers dsh to unload the old instance and load the new code, without a manual restart. This is the same underlying mechanism the primer and the services and lifecycle guide describe for plugin teardown in general — HMR is just that teardown-then-reload cycle triggered by a file change instead of a shutdown.

Step 8: Install it into a real profile

Once you're happy with the plugin, install it the same way any user would, from the local folder:

dsh plugin --profile web add ./hello-plugin

Remember the path is resolved relative to where you run the command, not the profile directory. Because dsh plugin just forwards its arguments to pnpm, this is really pnpm add ./hello-plugin executed inside the profile's directory — dsh plugin --profile web add github:you/hello-plugin and dsh plugin --profile web add ./hello-plugin-0.1.0.tgz work the same way once you're ready to distribute it as a GitHub repo or a tarball instead. See How to Install DeepSeek-Harness Plugins for the full breakdown of every supported source, and Installing DeepSeek-Harness Plugins from GitHub for what happens the first time a GitHub-sourced plugin needs a build step.

Step 9: Publish it

dsh.bundle.patch is the only thing that makes your package installable as a bundle — publishing is otherwise a completely ordinary npm publish / pnpm publish, as long as files includes both your compiled entry point and cordis.patch.yml. The full checklist — what to build before publishing, the GitHub topic that makes you discoverable, and how a package.json's dsh field is what lets a directory like FindHarness verify you're a real plugin rather than a project that merely mentions dsh — is in How to Publish a DeepSeek-Harness Plugin.

What each file is actually for

FilePurpose
package.jsonnpm metadata + the dsh.bundle.patch field that marks this package as a bundle
cordis.patch.ymlwhat this bundle inserts into the plugin tree — id, name, config
index.js (or lib/index.js)the plugin module — exports apply, optionally name, inject, Config
README.mdnot read by dsh, but expected by anyone browsing your repo or npm page

FAQ

Do I need a scaffolding tool to start a new plugin?

No — as of August 2026, dsh doesn't ship an official create-dsh-plugin-style CLI. The folder structure above is what the docs' own examples use, and it's small enough to write by hand.

Can I test a plugin without installing it into a profile?

Yes — --patch with an insert entry pointing at an absolute path (Step 6) loads a plugin without ever running dsh plugin add. That's the standard local-development loop; only reach for a real add once you're ready to depend on it from a profile's own package.json.

Does my plugin need to register a tool to be valid?

No. A module that only exports apply and does nothing with ctx is a valid, loadable plugin — it just isn't useful. Tools, commands, hooks, and MCP bridges are all optional things you register inside apply, not separate plugin types.

What if my plugin needs to depend on another plugin's service?

Declare it in inject, e.g. inject: ['tools', 'llm']. dsh delays calling apply until every injected service exists, and unloads the plugin if one disappears later — see Services, Dependency Injection, and Plugin Lifecycle for how that state machine works.

Next steps

Go deeper on the tool you just registered in How to Add a Custom Tool to DeepSeek-Harness with defineTool, make it configurable with Making Your Plugin Configurable with Schemastery, and when it's ready for other people, follow How to Publish a DeepSeek-Harness Plugin. For inspiration on what a real, shipped tool plugin looks like, browse modlens in the Tools & Capabilities category or the wider Development & Runtime listing on FindHarness.