Build your first DeepSeek Harness plugin
August 28, 2026 · View on GitHub
This tutorial takes one small capability through the complete lifecycle: load a local TypeScript module into a source checkout, expose a typed model tool, package the plugin as a bundle, install it into an isolated profile, inspect the composed graph, and remove it cleanly.
Warning
A Harness plugin is trusted Host code. It runs outside the Agent sandbox with the permissions of the dsh process. Review every dependency and install script, use a disposable profile, and pin Git dependencies to a commit.
Choose the right development path
| Objective | Use | What it proves |
|---|---|---|
| Learn the plugin API | source checkout plus --patch | the module loads and its lifecycle is correct |
| Test a model-facing tool | source checkout plus Web profile | the tool registry, schema, execution, and rendering work |
| Test real installation | bundle plus isolated profile | manifest discovery, pnpm installation, and layer reconciliation work |
| Publish for other users | built npm package or tarball | consumers receive runnable artifacts without your checkout |
Do not start by publishing. First prove the smallest local module, then the bundle, then the install path.
1. Verify the official source checkout
The official repository is deepseek-ai/deepseek-harness, and the official CLI package is @deepseek-ai/dsh.
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
git remote get-url origin
git rev-parse HEAD
corepack enable
pnpm install
pnpm run build
The remote must resolve to the DeepSeek AI repository. Record the commit because the plugin API is still in developer preview.
2. Create the smallest local plugin
From the repository root:
mkdir -p scratch-plugin/src
Create 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] loaded')
}
A plugin contributes capabilities through apply(ctx). Keep side effects inside that lifecycle. Cordis automatically disposes registrations made through the context; external resources must return an explicit disposer from ctx.effect().
Treat function-shaped apply returns as an integration boundary
Upstream Discussion #4455 reports a Cordis runner edge in which a plugin exported as an object with a plain function apply() is treated as constructible. In that reported shape, a returned disposer is not collected and a returned Promise is not awaited, so fiber.await() can look healthy while plugin work is still pending. This is a community field report, not proof that every release has the same behavior.
Keep the smallest apply(ctx) synchronous while validating a target runtime. Register cleanup through ctx.effect() and make asynchronous initialization explicit and observable at the supported lifecycle boundary. Test both a returned disposer and a rejected or never-settling initialization path through the exact composition loader; a direct module import does not exercise the runner. If a plugin requires asynchronous boot, pin the DSH/Cordis revision, record whether the loader actually awaits it, and fail closed rather than reporting an active row before its required work is ready.
Create scratch-plugin/cordis.yml, replacing the example with the absolute path to your checkout:
- insert:
- id: hello
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
For rc.2 on POSIX, the absolute path is a direct module specifier. Windows drive paths need the URL boundary below. Current 0.1.2-alpha.1 source additionally anchors an inserted ./ or ../ name to the overlay file itself.
Convert Windows drive paths to file URLs
If Node reports this error, the plugin file exists but its specifier has the wrong representation:
ERR_UNSUPPORTED_ESM_URL_SCHEME
On Windows, absolute paths must be valid file:// URLs.
Received protocol 'f:'
F:\repo\scratch-plugin\src\my-plugin.ts is an absolute Windows filesystem path, but Node's ESM loader parses the leading F: as an unsupported URL protocol. Do not hand-build the escaping. Generate the canonical URL in PowerShell:
$pluginPath = Join-Path $PWD 'scratch-plugin\src\my-plugin.ts'
node -e "const {pathToFileURL}=require('node:url'); console.log(pathToFileURL(process.argv[1]).href)" $pluginPath
Paste the printed value into the overlay:
- insert:
- id: hello
name: 'file:///F:/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
The drive letter, slash direction, spaces, #, and non-ASCII characters must remain exactly as pathToFileURL() encoded them. file://F:/..., a raw F:\... string, and a manually percent-escaped guess are not equivalent.
On current alpha source, this shorter overlay is also anchored relative to scratch-plugin/cordis.yml before the Loader sees it:
- insert:
- id: hello
name: './src/my-plugin.ts'
Use the generated file: URL when supporting rc.2 or an uncertain mixed prerelease installation. A successful path fix must print [hello-plugin] loaded; merely getting past the URL-scheme error can still expose a later TypeScript or dependency failure.
Give editor diagnostics an explicit project
The overlay above is the complete runtime configuration. It does not make scratch-plugin part of the repository's TypeScript Project Reference graph. If an editor or a direct tsc invocation reports Cannot find module '@deepseek-ai/cordis', add scratch-plugin/tsconfig.json:
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"composite": false,
"declaration": false,
"declarationMap": false,
"incremental": false,
"noEmit": true
},
"include": ["src"]
}
Then verify the scratch project directly:
pnpm exec tsc -p scratch-plugin/tsconfig.json
Extending tsconfig.base.json inherits the official source paths, including @deepseek-ai/cordis. The local file owns only the scratch project's inclusion and no-emit policy. Do not add scratch-plugin to the root Host aggregate, and do not add include or files to tsconfig.base.json; either change widens or narrows an official build graph merely to satisfy local editor discovery.
Boot the Web composition with the overlay:
pnpm dsh web --patch ./scratch-plugin/cordis.yml
Success means the terminal prints [hello-plugin] loaded and the Web UI still starts at http://127.0.0.1:3080.
3. Turn it into a typed Agent tool
Replace the module with:
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}!`
},
}))
}
inject = ['tools'] is a lifecycle dependency, not documentation. Cordis waits until the tool registry exists before calling apply. execute returns the canonical value; render converts that value into model-visible content.
Restart the command and ask:
Use the
greettool to greet Ada. Report the exact tool result.
Verify the tool name, validated argument, call result, and rendered text in the trace. A natural-language greeting without a tool call is not proof that the plugin ran.
For nested objects, explicit nulls, oneOf, and raw-schema compatibility, use the tool schema subset guide. The implicit parameter root and explicit value objects intentionally have different additionalProperties rules.
4. Add configuration without hardcoding deployment choices
Export both a TypeScript type and a same-named Schemastery schema:
import type { Context } from '@deepseek-ai/cordis'
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)
}
Then supply the value on the inserted row:
- insert:
- id: hello
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
config:
greeting: 'Hi'
Schema validation happens while the plugin loads. Put defaults and self-contained constraints in the schema so an invalid deployment fails early and visibly.
5. Package the plugin as an installable bundle
A bundle and a profile are different objects:
- the bundle is the package you ship; its
dsh.bundle.patchpoints to a configuration layer; - the profile is a runnable composition under
$DSH_HOME/profiles/<name>;dsh pluginmaintains its ordered bundle list.
Create this minimal built-JavaScript package:
hello-plugin/
├── package.json
├── cordis.patch.yml
└── index.js
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" } }
}
index.js:
export const name = 'hello-plugin'
export function apply() {
console.log('[hello-plugin] loaded')
}
cordis.patch.yml:
- insert:
- id: hello
name: dsh-hello-plugin
A package without the dsh.bundle declaration can still install as a dependency, but it contributes no composition layer.
6. Install, inspect, boot, and remove
From the directory containing hello-plugin:
dsh plugin --profile plugin-lab add ./hello-plugin
dsh --profile plugin-lab --dump-config
dsh --profile plugin-lab
dsh plugin --profile plugin-lab remove dsh-hello-plugin
The dump must show a dsh-hello-plugin layer and the inserted hello row before you boot. Install only one new bundle per test cycle so the first broken boundary remains attributable.
The effective layer order is:
- profile bundles in manifest order;
- the profile's
cordis.patch.yml; $DSH_HOME/cordis.patch.yml;--patchoverlays in command-line order.
Later layers win per row. A patch replaces the row's complete config value rather than deep-merging individual keys.
7. Publish without surprising consumers
Choose one distribution path:
| Distribution | Consumer behavior | Author responsibility |
|---|---|---|
| npm package | installs prebuilt artifacts | build before publish and include runtime files |
| packed tarball | installs a local immutable file | inspect pnpm pack contents and checksum the artifact |
| Git dependency | fetches source and may run prepare | make the build self-contained and document the exact commit |
pnpm 10 blocks Git dependency build scripts until the consumer explicitly allows them. That allowance executes package code on the Host during installation, outside the Agent sandbox. Treat it as a trust decision, not a routine setup checkbox.
Acceptance checklist
- The official repository and inspected commit are recorded.
- The local module loads through one explicit overlay.
- The scratch project type-checks through its own config when editor or CLI diagnostics are required.
- Every hard service dependency appears in
inject. - Tool arguments and canonical output are validated.
- External resources have a lifecycle disposer.
- The package contains its built entry point and patch file.
-
dsh.bundle.patchresolves inside the installed package. -
--dump-configshows the expected bundle and row. - A clean profile boots and invokes the tool.
- Removal deletes both the dependency and composition layer.
- Git installs are commit-pinned and build permission is explicit.
Failure router
| First failure | Inspect first |
|---|---|
| module not found | absolute local path or packaged main entry |
Windows ESM reports protocol f: or another drive letter | convert the exact absolute path with Node pathToFileURL() |
editor cannot resolve @deepseek-ai/cordis | scratch tsconfig.json extending the repository base config |
| plugin installs but no layer appears | dsh.bundle.patch in the installed manifest |
| service is not declared | missing inject or unsafe direct ctx.service access |
| configuration rejected | exported Schemastery Config schema and supplied row |
| tool never appears | tools injection, registration name, and active Agent composition |
| Git install has no built output | self-contained prepare plus pnpm allowBuilds decision |
| profile worked before install | manifest, lockfile, and dump-config diff from known-good state |
Official sources
- Your first plugin
- Build a tool
- Plugin configuration
- Package and install a plugin
- CLI profile and plugin contract
- TypeScript project layout and base-path contract
- alpha.1 overlay-relative plugin anchoring
- Windows absolute-plugin URL regression test
- Windows first-plugin report #4814
- Services and dependency lifecycle
- Function-shaped plugin
applycan drop cleanup and pending failures (#4455)