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

ObjectiveUseWhat it proves
Learn the plugin APIsource checkout plus --patchthe module loads and its lifecycle is correct
Test a model-facing toolsource checkout plus Web profilethe tool registry, schema, execution, and rendering work
Test real installationbundle plus isolated profilemanifest discovery, pnpm installation, and layer reconciliation work
Publish for other usersbuilt npm package or tarballconsumers 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 greet tool 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.patch points to a configuration layer;
  • the profile is a runnable composition under $DSH_HOME/profiles/<name>; dsh plugin maintains 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:

  1. profile bundles in manifest order;
  2. the profile's cordis.patch.yml;
  3. $DSH_HOME/cordis.patch.yml;
  4. --patch overlays 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:

DistributionConsumer behaviorAuthor responsibility
npm packageinstalls prebuilt artifactsbuild before publish and include runtime files
packed tarballinstalls a local immutable fileinspect pnpm pack contents and checksum the artifact
Git dependencyfetches source and may run preparemake 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.patch resolves inside the installed package.
  • --dump-config shows 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 failureInspect first
module not foundabsolute local path or packaged main entry
Windows ESM reports protocol f: or another drive letterconvert the exact absolute path with Node pathToFileURL()
editor cannot resolve @deepseek-ai/cordisscratch tsconfig.json extending the repository base config
plugin installs but no layer appearsdsh.bundle.patch in the installed manifest
service is not declaredmissing inject or unsafe direct ctx.service access
configuration rejectedexported Schemastery Config schema and supplied row
tool never appearstools injection, registration name, and active Agent composition
Git install has no built outputself-contained prepare plus pnpm allowBuilds decision
profile worked before installmanifest, lockfile, and dump-config diff from known-good state

Official sources