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:
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:
name: "my-plugin",
inject: ["tools"],
apply(ctx) { /* ... */ },
}
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:
Create scratch-plugin/src/my-plugin.ts:
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:
- id: hello
name: "/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts"
Boot the Web UI with the overlay:
[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(() => {
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:
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:
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:
- id: hello
name: "./src/my-plugin.ts"
config:
greeting: "Hi there"
maxRetries: 5
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:
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.ymlchange 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
configvalue (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:
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
parametersbeforeexecuteruns. - One canonical value —
executereturns exactly whatoutput.schemadeclares;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 notifications —
exec.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:
| Bundle | Profile | |
|---|---|---|
| Question | “What does this package contribute?” | “Which bundles compose this setup, in what order?” |
| Manifest | dsh.bundle → a patch file | dsh.profile → ordered bundles list |
| Role | What you author & distribute | What users boot with dsh --profile <name> |
A bundle is an npm package that ships a configuration layer. Create the package:
├── 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
"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:
- id: hello
name: dsh-hello-plugin
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:
$ 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:
- Each bundle patch named in the profile's
dsh.profile.bundles, in list order. - The profile's own
cordis.patch.yml. - The home-level
$DSH_HOME/cordis.patch.yml— machine-local preferences shared by every profile. - 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
preparescript 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
prepareuntil allowed. Copy the exact package key into the profile'spnpm-workspace.yamlasallowBuilds: { dsh-hello-plugin: true }and re-runadd.
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.