This tutorial assumes you have a deepseek-harness checkout that has gone through the run-from-source path (dependencies installed). Everything else is two files.
File one: the plugin itself
scratch-plugin/src/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!')
}
That is the entire plugin. No entry-point registration, no config template, no boilerplate. Export a name, export an apply, done.
File two: making the framework aware of it
A patch file tells the Web UI to insert the local plugin:
scratch-plugin/cordis.yml
- insert:
- id: hello
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
The path must be absolute — a patch contributes configuration but does not change the profile directory the loader resolves module paths from.
Run it
pnpm dsh web --patch ./scratch-plugin/cordis.yml
Open http://127.0.0.1:3080. In the terminal: [hello-plugin] plugin loaded!. Your plugin is running inside the harness.
Make it do something
Logging is not a capability. The typical move is to register something through ctx — the docs' canonical example is registering a tool, which means declaring the tools service first:
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(/* ... */)
}
The framework waits for tools to be ready, then calls apply — ctx.tools is there, no waiting on your side. (The exact tool DSL lives in the docs' tool chapter; the point here is the registration path: declare in inject, register on ctx.)
Learn the cleanup contract
Anything registered on ctx is cleaned up automatically on unload. For a resource that needs explicit teardown, ctx.effect() takes the disposer:
ctx.effect(() => {
const timer = setInterval(() => console.log('heartbeat'), 5000)
return () => clearInterval(timer)
})
The returned function runs when the plugin unloads. A setInterval like this would actually be cleaned up anyway — but a network connection is the kind of resource where ctx.effect is the right call. The rule of thumb: if it has a close/dispose step, wrap it in ctx.effect.
Ship it to the ecosystem
A finished plugin enters the DSH ecosystem the plain way:
- Push it to a public GitHub repository;
- Add the
dsh-plugintopic; - Write a README that says what it does, how to install it and what permissions it asks for;
- Anyone else installs it with
dsh plugin --profile web add github:owner/repo.
This is exactly the signal set dsh-plugin.work's index works from: is there a README, is the license clear, has there been recent activity. The index does not track repository names — it tracks the evidence that a plugin is trustworthy and maintained.
Afterword
Getting this running takes under ten minutes, but it is worth a bit more of your time: a plugin is code, composition happens through declared dependencies, and lifecycle is owned by the framework. DSH has pushed most of the engineering complexity of a plugin ecosystem down to a level the author barely notices. The remaining job is writing something good into apply(ctx).