SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor · Updated 2026-08-21

Write your own dsh plugin for DeepSeek Harness

Build a dsh plugin from an empty folder: the package.json fields that matter, the patch file that mounts it, a real tool, and the two hooks you need.

What a dsh plugin actually is

A dsh plugin is an npm package that exports an apply function and ships one small YAML file telling DeepSeek Harness to load it. There is no separate plugin SDK to learn first. dsh is a Cordis application, and "everything is a plugin" is literal: the tool registry, the agent loop, the session store and the web server are all rows in the same plugin tree that your package joins.

Cordis is a general composition framework, built independently and used for years as the base of the Koishi chatbot framework. It handles loading and unloading, and it resolves the dependencies between plugins. It knows nothing about agents. Everything agent shaped comes from the harness packages layered on top of it, which is why the plugin shape below looks so small. Most of what you get is inherited.

A plugin has two halves. The host half runs in Node, registers tools and event listeners, and can provide services of its own. The browser half runs inside the Web UI and registers interface slots. A first plugin is almost always host only, so treat the browser half as optional until you need it.

This guide was written against @deepseek-ai/dsh version 0.1.0-rc.7, the npm latest tag on 19 August 2026. dsh is a developer preview and its own README says there will be compatibility breaking changes. Every key name below was read from the upstream documentation and the repository on that date. Read them again before you depend on one, because a preview API renames fields between release candidates. If the harness is not running yet, set it up with DeepSeek Harness on a VPS and the dsh API key and model configuration first, then come back here.

Load one scratch file before you package anything

Packaging first is the slow way to learn this. Load a single file, prove the runtime calls your code, then package it.

Create a folder outside the harness checkout and put one file in it.

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

export const name = 'hello-plugin'

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

export const name is metadata used to label the plugin in diagnostics. apply is the whole contract: Cordis calls it once and passes a context scoped to your plugin. Anything you register on that context is undone for you when the plugin is disposed.

Beside it, write cordis.yml.

- insert:
    - id: hello
      name: '/absolute/path/to/scratch-plugin/hello.ts'

Now boot a profile with that file layered on top.

dsh web --patch ./scratch-plugin/cordis.yml

If dsh is not on your PATH, npx @deepseek-ai/dsh web --patch ./scratch-plugin/cordis.yml does the same job. That npx route can hand you a cached older release candidate rather than the version this guide describes, so if the harness rejects a documented flag outright, work through the fixes for dsh install and version errors before you start doubting your own file. You should see [hello-plugin] plugin loaded in the terminal that started dsh. If nothing appears, the row did not resolve.

The name field takes an npm package name or a filesystem path, and the upstream documentation states the path must be absolute. A relative ./hello.ts is the first thing to check when a scratch plugin produces no output. The second is the file extension. The documented loop is run as pnpm dsh web --patch ... from a clone of the harness repository, where TypeScript entries load through tsx. If your dsh came from npm, point the row at plain JavaScript, or build the file first.

--patch is a launcher flag and its overlay is applied last, after every bundle and after your own profile patch. A scratch overlay therefore always wins, which is exactly what you want while you iterate.

Write the smallest tool that does something useful

A log line proves the plugin loads. A tool proves the plugin is part of the agent.

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}!`
    },
  }))
}

export const inject = ['tools'] is the line people leave out. Entries in a Cordis configuration start concurrently, so a row's position in the file guarantees nothing about load order. Ordering comes from declared dependencies. inject tells Cordis to wait until ctx.tools exists before it calls your apply, and without it your code can run at a moment when the registry is not there to register against.

The rest of the object is the contract the model sees. parameters is the argument schema, and execute receives arguments already parsed against it. output.schema describes the value execute returns, while render converts that value into the content blocks the model reads. Keeping those two separate is what lets the interface show one thing while the model reads another.

Start the profile and ask the assistant to greet someone by name. The reply comes back through your execute. Registration through ctx is reversible, so disposing the plugin unregisters the tool for you. For anything Cordis cannot know about, such as a socket or a file handle, call ctx.effect() and hand it a disposer.

The two extension points a first plugin really touches

The full list of seams is long. Two of them cover almost every first plugin.

Conversation events are the durable, logged stream. The names are session/event, turn/start, turn/end, step/start, step/end, user/message, assistant/message, assistant/chunk, tool/call and tool/result. You attach an ordinary listener.

ctx.on('tool/call', (payload) => {
  console.log('[my-plugin] tool/call', JSON.stringify(payload))
})

Print the payload once and read it. Do not copy payload field names out of any guide, this one included, because payload shapes are the part of a preview API that moves most.

The second extension point is the waterfall. agent/pre-step, agent/request, agent/request-error, llm/stream and the tools/* events are waterfalls, and a waterfall listener has a different signature. It takes a next callback, and the chain continues only if it calls it.

ctx.on('agent/request', async (payload, next) => {
  const startedAt = Date.now()
  const downstream = await next()
  console.log('[my-plugin] model request took', Date.now() - startedAt, 'ms')
  return downstream
})

If you forget await next(), you have not added a hook. You have replaced the model call with nothing, and the agent stops there, because short circuiting is the designed behaviour for a gateway plugin that denies a request on purpose. That one difference causes most first-plugin confusion. Write the next() call before you write anything around it.

agent/request wraps the model call itself. Its payload carries the agent making the call, the open turn number, the step the request belongs to and that turn's abort signal, which is what makes it the right seam for a request logger or a rate limiter. The tools/* waterfalls are the same shape one layer down. tools/pre-execute allows, denies or requests approval before dispatch. tools/execute wraps the dispatch. tools/post-execute can replace or block the normalised result. tools/result only observes the frozen outcome.

Package it as a bundle other people can install

A bundle is an npm package whose package.json declares a dsh.bundle field pointing at its patch file. That declaration is the entire difference between a scratch file and something installable.

{
  "name": "dsh-plugin-hello",
  "version": "0.1.0",
  "type": "module",
  "main": "lib/index.js",
  "files": ["lib", "cordis.patch.yml", "README.md", "LICENSE"],
  "engines": { "node": "^22.19 || >=24", "dsh": ">=0.1.0-rc.6" },
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
  "keywords": ["dsh-plugin", "deepseek-harness"],
  "scripts": { "build": "tsdown", "prepare": "pnpm run build" },
  "exports": {
    ".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" },
    "./cordis.patch.yml": "./cordis.patch.yml",
    "./package.json": "./package.json"
  }
}

The cordis.patch.yml that sits beside it is short.

- insert:
    - id: dsh-plugin-hello
      name: dsh-plugin-hello

The row name is the package name, so those two strings must match. The row id is what a later layer targets when a user overrides your configuration, so pick something stable and never reuse it for a different plugin.

files must list cordis.patch.yml. Leave it out and the published tarball carries a dsh.bundle.patch pointing at a file that was never packed, so the package installs and contributes nothing to the tree.

Install it into a profile from the directory that contains your plugin folder.

dsh plugin --profile demo add ./dsh-plugin-hello
dsh --profile demo --dump-config
dsh --profile demo

dsh plugin --profile <name> forwards the rest of its arguments to pnpm inside that profile directory, so add and remove behave the way pnpm does. Uninstall with dsh plugin --profile demo remove dsh-plugin-hello. The web and headless profiles create themselves from shipped templates on first use, and any other profile name has to be created through dsh plugin.

Why your row is missing from the composed tree

Composition starts from an empty entry list and stacks layers in a fixed order. Each bundle named in the profile's dsh.profile.bundles, in the order listed. Then the profile's own cordis.patch.yml. Then $DSH_HOME/cordis.patch.yml. Then any --patch overlay from the command line. Later layers replace earlier rows by id.

Profiles live under $DSH_HOME/profiles/<name>. A profile directory holds a package.json carrying the dsh.profile manifest with its ordered bundles list, plus the user's own patch file. Bundle names resolve from the dsh installation first and from the profile's node_modules second, which is where pnpm puts an out of tree plugin.

dsh --profile demo --dump-config prints the fully composed tree without booting anything, and that output is the dividing line for debugging. If your row id is absent, the problem is composition: a name that does not resolve, or a patch file that was never packed. If the row is present and nothing happens, the problem is your code. Answer that question first and you skip most of the guesswork.

Where loading errors actually surface

An error thrown inside apply is loud. The process exits with that exception and you get a stack trace pointing at your own line.

Resolution failures are quiet. The loader reports a module it cannot resolve through the Cordis logger instead of crashing, and the upstream tutorial warns that these messages can appear lost at startup, because they are emitted before console exporters are attached. A path typo therefore looks exactly like a plugin that loaded and did nothing, which is why the --dump-config check above is worth running before you read any code.

Keep a console.log as the first statement in apply while you develop. Its absence tells you which half of the problem you have, and it costs nothing to delete later. On a server, run the harness in the foreground while you iterate rather than under a service manager, so loader output reaches your terminal instead of a journal you have to go and read.

Iterating without restarting the world

The honest answer for the host half today is that you restart. The web application bundle ships with its shared hot module reload row disabled, and the file carries a note saying it will be re-enabled once the reload lifecycle has been tested. The client side reload chain is always mounted but stays idle until a rebuild watcher rewrites the client bundles, so it does nothing for your Node half either.

Make the restart cheap instead of chasing a reload that is not there yet. Keep the plugin in one file. Load it with --patch rather than installing it into a profile, so no build step and no pnpm step sit between an edit and a run. Register everything through ctx so a restart cannot leave a duplicate tool or a stale listener behind. Wrap anything you allocate yourself in ctx.effect() with a real disposer, because the usual symptom of a missing disposer is the second run failing on a port the first run still holds.

If you develop against a harness running on a server rather than your laptop, none of the above changes, but the Web UI binding does matter. The loopback bind on port 3080 explains why the page does not open by itself and what to do about it.

The browser half, and how much to trust it

Add this only when your plugin needs its own interface. It is declared in the same dsh field as the bundle.

{
  "dsh": {
    "client": {
      "platform": "web",
      "inject": [],
      "external": [],
      "immediately": false
    }
  },
  "exports": {
    ".": "./src/index.ts",
    "./client": "./src/client/apply.ts",
    "./package.json": "./package.json"
  }
}

"platform": "web" is required, and the scanner throws if the package has no ./client export, so the export map is part of the manifest rather than a convenience. The client entry receives the Cordis Context widened with the client runtime type, and every registration happens inside apply through ctx.slots.register. Module level side effects are not allowed there.

import type { Context } from 'cordis'
import type { DshClientContext } from '@deepseek-ai/dsh-client-runtime'

export async function apply(ctx: Context & DshClientContext) {
  ctx.slots.register({ name: 'domain.entry.slot' }, MyComponent)
}

Two details are worth knowing before you start. inject in the client manifest is documentation rather than scheduling: it records package level dependency edges and does not control activation order. external is where you declare module requests outside the baseline, so they are materialised before your plugin asks for them. This is the fastest moving corner of the preview, so read packages/client/AGENTS.md in the harness repository on the day you write the code, not on the day you read a guide about it.

Publish, and say what your plugin touches

Adding the dsh-plugin topic to a GitHub repository puts it in the list people browse when they go looking for plugins. That is a claim on a stranger's trust, and it carries obligations. Those obligations are the mirror image of what our guide to vetting a dsh plugin before you install it tells readers to check, so writing to that checklist is the easiest way to pass it.

  • Pin your dependencies. A caret range on a transitive dependency is how a package that was safe last week runs different code this week, which is the exact mechanism behind npm supply chain attacks on a server.
  • Make the manifest say what you touch. Your inject list is an honest, machine readable summary of which harness services you take. A reviewer reads it in seconds and forms an opinion from it.
  • No silent network calls. If a tool calls an API, name the host in the README and make the endpoint configurable. A plugin that contacts a server it never mentioned will be delisted by the people who audit these things.
  • Keep files tight. Publishing a whole working folder is how a stray credential file reaches the registry.
  • Give git installers a prepare script that builds without dev-only assumptions, and tell them in the README that they must allowlist that build in their profile's pnpm-workspace.yaml.
  • Date stamp the README against the release candidate you built and tested against. Readers of a preview API need to know which one you had.

To see what a finished plugin looks like from the outside, read the dsh plugins worth installing and notice what each README tells you before you install it. If you have written extensions for another agent, how Claude Code plugins are put together is a useful contrast. The harness hands you a live object graph and reversible registration, which is more power than a manifest of files, and more responsibility with it.

FAQ

Do I need to publish to npm to write a dsh plugin?

No. A filesystem path in a cordis.yml overlay, loaded with dsh web --patch ./scratch-plugin/cordis.yml, is enough to run your own code inside the harness. The path must be absolute. Packaging matters only when someone else installs the plugin, and even then you can install a local folder with dsh plugin --profile demo add ./my-plugin to test the packaged form without touching a registry.

Why does my plugin load but the tool never appear?

Run dsh --profile demo --dump-config first. If your row id is missing from that output, the plugin never mounted and the cause is composition rather than code. If the row is present, check for export const inject = ['tools']. Entries in a Cordis configuration start concurrently, so file order does not decide load order. Without that declaration Cordis does not wait for the tool registry, and your apply can run at a moment when ctx.tools is not available to register against.

What is the difference between cordis.yml and cordis.patch.yml?

cordis.yml is a full entry list. cordis.patch.yml is a layer applied on top of one, targeting rows by id to insert new ones or replace an existing configuration. A bundle points at its own patch file through dsh.bundle.patch in package.json. Layers apply in a fixed order: every bundle in the profile's listed order, then the profile's patch file, then $DSH_HOME/cordis.patch.yml, then any --patch overlay. Later layers win.

Can I hot reload a dsh plugin while the agent is running?

Not for the host half in the web profile, as of 0.1.0-rc.7. That bundle ships the shared hot module reload row disabled, with a note in the file saying it returns once its reload lifecycle has been tested. Design for a fast restart instead: one file, loaded through --patch with no build step, and every registration made through ctx so nothing leaks from one run into the next. Use ctx.effect() with a disposer for resources Cordis cannot clean up on its own.