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 applyctx.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:

  1. Push it to a public GitHub repository;
  2. Add the dsh-plugin topic;
  3. Write a README that says what it does, how to install it and what permissions it asks for;
  4. 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).