dsh.so

Plugin Development

Everything in DeepSeek Harness is a plugin. This tutorial walks end-to-end — from a minimal local plugin, through configuration and tools, to a published npm bundle — grounded in the official docs (verified 2026-08-13).

What is a plugin?

A plugin is a TypeScript module that exports an apply function. The framework calls apply when the plugin loads and passes a context object (ctx) through which the plugin registers capabilities:

my-plugin.ts
import type { Context } from "@deepseek-ai/cordis"
export const name = "my-plugin"

export function apply(ctx: Context) {
// Register capabilities here.
}

That is the entire contract. name identifies the plugin in logs and overlays; apply is where you register event listeners, tools, services, timers, or UI. Everything you register through ctx is an effect: it is automatically undone when the plugin unloads, so plugins compose without leaking state.

Three plugin forms

The function form above covers most cases. A plugin may also be an object or a class:

object form
export default {
name: "my-plugin",
inject: ["tools"],
apply(ctx) { /* ... */ },
}
class form
import { Service, type Context } from "@deepseek-ai/cordis"

export default class MyService extends Service {
static inject = ["tools"]
constructor(ctx: Context) {
super(ctx, "myService")
// Synchronous init in the constructor.
}
}

Use the class form when your plugin provides a service other plugins consume; the service name (here myService) is what other plugins list in inject.

Your first plugin (local)

Start from a repo checkout that completed the run-from-source path. Create a scratch project:

zsh — dsh
$ mkdir -p scratch-plugin/src

Create scratch-plugin/src/my-plugin.ts:

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!")
}

Register it with an overlay

Run pwd from the repo root, then create scratch-plugin/cordis.yml. The plugin path must be absolute — a patch file contributes configuration but does not change the profile directory the loader resolves module paths from:

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

Boot the Web UI with the overlay:

zsh — dsh
$ pnpm dsh web --patch ./scratch-plugin/cordis.yml
[hello-plugin] plugin loaded!
$

Open http://127.0.0.1:3080 — the terminal line prints during startup. --patch overlays are config layers applied after everything else; see loading order below.

Context: cleanup & dependencies

Automatic cleanup

Anything registered through ctx — event listeners, tools, timers — is cleaned up when the plugin unloads. You never call removeListener or clearInterval manually. For a resource that needs explicit disposal (a network connection, a file watcher), provide the disposer with ctx.effect():

ctx.effect()
export function apply(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => console.log("heartbeat"), 5000)
// Runs when the plugin unloads.
return () => clearInterval(timer)
})
}

This is why hot replacement works: configuration edits (HMR) unload the old instance and load a new one, and because registrations are effects, the old instance leaves nothing behind.

Declaring dependencies with inject

If your plugin consumes a service — tools, llm, or another plugin's service — declare it in inject. The framework waits for every required service before it loads your plugin:

inject
export const name = "my-tool-plugin"
export const inject = ["tools"]

export function apply(ctx: Context) {
// ctx.tools is ready here.
ctx.tools.register(/* ... */)
}

Configuration

Export a Config type and a same-named Schemastery schema. Defaults live directly on the schema fields; Cordis validates user config and fills defaults when the plugin loads:

config.ts
import type { Context } from "@deepseek-ai/cordis"
import Schema from "@deepseek-ai/schemastery"

export const name = "my-plugin"

export interface Config {
greeting: string
maxRetries: number
verbose?: boolean
}

export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default("Hello"),
maxRetries: Schema.number().default(3),
verbose: Schema.boolean().default(false),
})

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

Users supply values in the plugin row of cordis.yml:

cordis.yml
- insert:
- id: hello
name: "./src/my-plugin.ts"
config:
greeting: "Hi there"
maxRetries: 5
Do not export a plain object as Config — it must implement the Standard Schema interface Cordis requires. Schemastery gives you that for free.

Strict validation

Schemastery expresses richer constraints — required fields, unions, numbers with bounds. Invalid configuration fails the plugin load with an actionable error:

validated config
export const Config = Schema.object({
apiKey: Schema.string().required(),
timeout: Schema.number().default(30000),
mode: Schema.union(["fast", "accurate"]).default("fast"),
})

Design principles

  • No hardcoded tunables — anything two deployments may set differently must be a config field. The test: can cordis.yml change it without a code edit?
  • Fail loudly — express self-contained constraints in the schema so invalid config fails at load time, not at first use.
  • Defaults users keep — later config layers replace a row's entire config value (no deep merge), so ship defaults most users won't override.

Building tools

Tools are what the model can call. Register one with defineTool from @deepseek-ai/dsh-tools — the DSL infers and validates args from parameters:

greet 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" },
},
output: {
schema: { type: "string" },
render: (_args, value) => [{ type: "text", text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}

Restart pnpm dsh web --patch ./scratch-plugin/cordis.yml, open http://127.0.0.1:3080, and ask “Use the greet tool to greet Ada.” The model calls greet and receives Hello, Ada!.

The execute() contract

  • Args are validated for you — types, required keys, literals, unions, and nested values are checked against parameters before execute runs.
  • One canonical valueexecute returns exactly what output.schema declares; output.render(args, value) converts it to model-facing content. Never return content blocks from the body.
  • Throw = isError — infrastructure failures throw; a successful domain outcome is represented in the canonical value (e.g. a non-zero process exit) even when the renderer explains it.
  • Honor exec.signal — cancel in-flight work when it fires (it is the caller-owned abort signal).
  • Async notificationsexec.agent.inject({ content, source }) appends durable context the next model request sees; it does not wake an idle agent.

For long-running work, gate run_in_background behind config and register through ctx.jobs.start({ kind, label, owner: exec.agent, run }) — a successful background branch returns a typed handle like { kind: "background", jobId }. See the tool authoring reference for the full contract.

Package & publish

The local --patch flow is for development. Distribution rests on two concepts, both described by a package.json but carrying different manifests under the dsh key:

BundleProfile
Question“What does this package contribute?”“Which bundles compose this setup, in what order?”
Manifestdsh.bundle → a patch filedsh.profile → ordered bundles list
RoleWhat you author & distributeWhat users boot with dsh --profile <name>

A bundle is an npm package that ships a configuration layer. Create the package:

hello-plugin/
hello-plugin/
├── package.json # declares dsh.bundle
├── cordis.patch.yml # the layer applied when a profile lists this bundle
└── index.js # plugin modules the patch rows reference
package.json
{
"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" } }
}

The patch references the package by name (not a source path) so Node resolution finds the installed code:

cordis.patch.yml
- insert:
- id: hello
name: dsh-hello-plugin
A package without dsh.bundle still installs, but only as a plain dependency: dsh plugin warns and activates no layer. Use that form for libraries plugins import, not for plugins users enable.

Install into a profile

dsh plugin --profile <name> <args> forwards to pnpm inside the profile directory, so every pnpm verb works. First use initializes the profile with @deepseek-ai/dsh-base as its first bundle:

zsh — dsh
$ dsh plugin --profile demo add ./hello-plugin
$ dsh --profile demo --dump-config # verify the layer without booting
$ dsh --profile demo

dsh plugin --profile demo remove dsh-hello-plugin removes both the dependency and its layer. To distribute, publish the bundle to npm — users install with dsh plugin --profile web add dsh-hello-plugin.

Loading order

The effective configuration composes over an empty root by applying, in order:

  1. Each bundle patch named in the profile's dsh.profile.bundles, in list order.
  2. The profile's own cordis.patch.yml.
  3. The home-level $DSH_HOME/cordis.patch.yml — machine-local preferences shared by every profile.
  4. Each --patch <path> overlay, in argv order.

Later layers win per row, and a patch replaces a row's entire config rather than deep-merging keys. As a bundle author you can override earlier rows by id — but must restate every key the row needs, not just the changed one.

Installing from GitHub

Publishing to npm is not required — users can install straight from a git host with dsh plugin --profile demo add github:you/hello-plugin. But a git install fetches sources, not built artifacts: nothing runs your build script, so a TypeScript package arrives without its lib/ output. Two things must happen:

  • Author: ship a prepare script that builds the published entry points from source, self-contained (pnpm runs it after a git install). turtle-ui is a working example.
  • User: allowlist the build — pnpm ≥ 10 refuses to run a git dependency's prepare until allowed. Copy the exact package key into the profile's pnpm-workspace.yaml as allowBuilds: { dsh-hello-plugin: true } and re-run add.
That allowance is permission to execute the package's code on your machine at install time. Only allow packages whose source you trust, and pin a commit (github:you/hello-plugin#<sha>) so a later push cannot silently change what runs. Prefer npm or a tarball (pnpm pack) to avoid the allowance entirely.

Real examples & resources

Study production plugins in the registry: modlens (dsh.bundle), dsh-taskboard (dsh.client Web UI). Official docs — first plugin · config · tool · publish · tool authoring reference · Cordis tutorial. Ready to share? See Submit a Plugin.