Write your own plugin

Go from user to contributor: a plugin is just a module that exports apply(ctx). Write a minimal plugin, register a model-callable tool, and package it as a bundle for npm or GitHub.

10 min read

What you'll be able to do

  • Understand what a DSH plugin actually is (a module that exports apply(ctx))
  • Write a minimal plugin and load it into the Web UI
  • Add a tool the model can call
  • Package it as an installable bundle and publish it for others

This lesson is for developers who want to contribute plugins to the ecosystem; it assumes basic TypeScript. If you're just using DSH, feel free to skip it.

What a plugin really looks like

First, demystify it: "everything is a plugin" in DSH is not a metaphor. Every capability — the tool registry, model adapters, the UI, even the main loop — is a plugin, and a plugin you write has exactly the same standing as the official ones.

A plugin is a module that exports three things:

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

export const name = 'my-plugin'     // plugin identity
export const inject = ['tools']     // services it depends on
export function apply(ctx: Context) {
  // register capabilities here
}
  • name: the plugin's identity, shown in diagnostics;
  • inject: dependency declarations — the framework waits for these services before running apply, so load order is decided by dependencies, not file order;
  • apply(ctx): the single entry point. ctx carries all services (ctx.tools, ctx.llm, …), and everything you register through it is cleaned up automatically when the plugin unloads — no manual removeListener.

The official developer tutorials live in docs/user/develop/basic/ in the repository; this lesson is a condensed tour of them.

Step 1: run a minimal plugin

The easiest development setup is running from the official repository source (which gives you the built-in pnpm dsh command):

git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install && pnpm run build

From the repository root, create a scratch directory and a plugin file:

scratch-plugin/
└── src/
    └── my-plugin.ts

In my-plugin.ts:

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

export const name = 'hello-plugin'

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

Then create scratch-plugin/cordis.yml (the plugin path must be absolute — replace /absolute/path/to/ with the real repository path on your machine):

- insert:
    - id: hello
      name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'

Start with that overlay:

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

Open http://127.0.0.1:3080. When [hello-plugin] plugin loaded! appears in the startup log, your first plugin is alive.

Step 2: register a tool the model can call

Replace my-plugin.ts with a version that registers a tool:

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' },
    },
    async execute(args) {
      return `Hello, ${args.name}!`
    },
  }))
}

Restart (same --patch command), then say in the chat: "Use the greet tool to greet Ada." The model calls greet and receives Hello, Ada! as the tool result.

Three things to note:

  1. name is what the model calls by — make it descriptive;
  2. description decides whether the model thinks to use the tool at all; state the triggering scenario clearly;
  3. parameters declares the input schema, which the framework validates calls against.

Step 3 (optional): accept user configuration

A plugin can export a Config schema so users can change its behavior from cordis.yml:

import Schema from '@deepseek-ai/schemastery'

export interface Config {
  greeting: string
}

export const Config: Schema<Config> = Schema.object({
  greeting: Schema.string().default('Hello'),
})

export function apply(ctx: Context, config: Config) {
  console.log(config.greeting) // user value or schema default
}

Design principle: anything two deployments may want to set differently should be a configuration field — don't hardcode it.

Step 4: package and publish

For local experiments, --patch is enough. To distribute, you need to shape the plugin as a bundle — an installable npm package:

hello-plugin/
├── package.json       # declares dsh.bundle
├── cordis.patch.yml   # the config layer this package contributes
└── index.js           # plugin entry

The key in package.json is the dsh field:

{
  "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" } }
}

In cordis.patch.yml, the plugin row references the package name instead of a path:

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

Verify by installing locally into a profile:

dsh plugin --profile web add ./hello-plugin
dsh --profile web --dump-config   # a "# == dsh-hello-plugin" layer means it mounted

Three ways to publish:

ChannelCommandNotes
npmdsh plugin --profile web add your-packageShip prebuilt lib/ at publish time — users install with no build and no authorization prompt. Best experience
GitHubdsh plugin --profile web add github:you/hello-pluginFetches sources, not artifacts — users must go through the allowBuilds authorization, and the author must ship a self-contained prepare script
tarballdsh plugin --profile web add ./hello-plugin-0.1.0.tgzpnpm pack output; also needs no build permission. Good for internal distribution

Prefer npm. If you distribute via GitHub, state clearly in the README which dsh version you target — DSH is still in developer preview and its APIs will see breaking changes.

Four rules of plugin authoring

From the official docs and incident retrospectives — violating these doesn't throw errors, it just behaves weirdly:

  1. Named exports only: never export default. The loader folds the default export into the plugin body and silently drops inject and other metadata — your dependency declarations vanish;
  2. No module-level side effects: a module is imported once, but a plugin may be mounted and unmounted many times. Anything like setInterval must live inside apply wrapped in ctx.effect(), or it survives the unload forever;
  3. Patches shallow-override, they don't deep-merge: when overriding a row's config, restate every key you want to keep — writing one key deletes the rest;
  4. New rows need insert: using override syntax to add a row fails with entry not found.

First response when debugging

For any "I changed the config but nothing happened", dump the final config tree instead of guessing:

dsh --profile web --dump-config

It prints the composition of all layers (bundle layers → profile layer → home layer → --patch overlays), so you can see at a glance where your change got swallowed.

Next up

Want more people to discover your plugin? Tag the GitHub repository with the dsh-plugin topic — that's where community plugins get discovered, and it's how this site's plugin directory picks them up.

FAQ →