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 runningapply, so load order is decided by dependencies, not file order;apply(ctx): the single entry point.ctxcarries 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:
nameis what the model calls by — make it descriptive;descriptiondecides whether the model thinks to use the tool at all; state the triggering scenario clearly;parametersdeclares 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:
| Channel | Command | Notes |
|---|---|---|
| npm | dsh plugin --profile web add your-package | Ship prebuilt lib/ at publish time — users install with no build and no authorization prompt. Best experience |
| GitHub | dsh plugin --profile web add github:you/hello-plugin | Fetches sources, not artifacts — users must go through the allowBuilds authorization, and the author must ship a self-contained prepare script |
| tarball | dsh plugin --profile web add ./hello-plugin-0.1.0.tgz | pnpm 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:
- Named exports only: never
export default. The loader folds the default export into the plugin body and silently dropsinjectand other metadata — your dependency declarations vanish; - No module-level side effects: a module is imported once, but a plugin may be mounted and unmounted many times. Anything like
setIntervalmust live insideapplywrapped inctx.effect(), or it survives the unload forever; - 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; - New rows need
insert: using override syntax to add a row fails withentry 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.