Once your plugin works inside the source repository, the next step is packaging it into a reusable, distributable form. This tutorial explains the two core concepts of DeepSeek Harness packaging — bundles and profiles — and walks you through declaring a local plugin as a dsh.bundle, installing it into a profile with dsh plugin add, understanding how config layers compose, and the differences between local path, GitHub, npm, and tarball installs.

Five-step packaging flow: write the plugin module, declare dsh.bundle in package.json, write cordis.patch.yml, install into a profile with dsh plugin add, verify on boot1Write pluginindex.js exports apply2Declare bundlepackage.json3Write patchreference by package name4Add to profiledsh plugin add5Verify--dump-configA bundle answers “what it contributes”; a profile answers “what composes this setup, and in what order”.
Five steps from code to a running setup: write the plugin, declare the bundle, write the patch, add to a profile, verify.

Two concepts: bundle and profile

Packaging involves two concepts. Both are package.json files — they simply carry different manifests under the dsh key:

  • BUNDLE: an npm package that ships a configuration layer. Its manifest declares dsh.bundle and answers “what does this package contribute?” — usually a patch file that inserts or overrides plugin rows;
  • PROFILE: a directory under $DSH_HOME/profiles/ (named after the profile) describing one runnable composition. Its manifest declares dsh.profile and answers “which bundles compose this setup, and in what order?”.

Nothing is both: a package either contributes layers or composes them.

Bundle layout and manifest

Using hello-plugin as the example, a bundle package looks like this:

Bundle directory structure
hello-plugin/
├── package.json
├── cordis.patch.yml
└── index.js

Alongside the usual npm fields, package.json declares this as a bundle with dsh.bundle:

package.json (bundle)
{
  "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" } }
}

Three things matter: files must include cordis.patch.yml, or the layer is missing after install; dsh.bundle.patch points at the layer file; main points at the plugin entry.

The plugin module itself is no different from a regular plugin — index.js exports name and apply:

index.js
export const name = 'hello'

export function apply(ctx) {
  console.log('[hello] loaded from dsh-hello-plugin')
}

Writing cordis.patch.yml

cordis.patch.yml is the heart of a bundle: it declares which plugin rows the package inserts into (or overrides in) the configuration.

cordis.patch.yml
- insert:
    - id: hello
      name: dsh-hello-plugin

Note that name holds the package name dsh-hello-plugin, not a relative path. Once the bundle is installed into a profile, its files live inside the profile’s node_modules, and only the package name lets Node resolution find the installed code; a relative path would break after installation.

Conversely, a package lacking dsh.bundle still installs, but only as a plain dependency — dsh plugin warns and activates no layer. Libraries a plugin package imports follow this path: imported by plugins, but contributing no config themselves.

Install into a profile and verify

Run this from the directory that contains hello-plugin:

Add the local bundle to the demo profile
dsh plugin --profile demo add ./hello-plugin

The first time you use the demo profile, three things happen: the profile is initialized with @deepseek-ai/dsh-base as its first bundle; pnpm links your local checkout; and dsh appends the bundle to the profile’s dsh.profile.bundles list. Afterwards, the profile’s package.json looks roughly like this:

The profile’s package.json (excerpt)
{
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "dsh-hello-plugin"
      ]
    }
  }
}

Inspect the merged config without booting

Use --dump-config to see the merged configuration and confirm the bundle’s layer is in effect:

Dump the merged config
dsh --profile demo --dump-config

A layer labeled with the package name in the output means the profile references the bundle:

--dump-config output (excerpt)
# == dsh-hello-plugin
- insert:
    - id: hello
      name: dsh-hello-plugin

Boot and remove

Once it looks right, boot the profile — the plugin load line in the startup log means success:

Boot the demo profile
dsh --profile demo

Removal is a single command too:

Remove the bundle from the profile
dsh plugin --profile demo remove dsh-hello-plugin

Layer composition and loading order

When a profile boots, configuration is layered in a fixed order, and later layers win per row:

  1. each bundle patch in the dsh.profile.bundles list, in list order (@deepseek-ai/dsh-base first, then each installed bundle in add order);
  2. the profile’s own cordis.patch.yml;
  3. the machine-level $DSH_HOME/cordis.patch.yml, shared across profiles;
  4. each --patch overlay passed on the command line, in argv order.
Layer composition order: dsh-base, other bundles in add order, the profile own patch, the DSH_HOME machine-level patch, command-line --patch overlays1dsh-basefirst bundle2Bundlesin add order3Profile patchown cordis.patch.yml4Home patch$DSH_HOME, machine-wide5--patchin argv orderEach later layer replaces earlier rows whole — no deep merge.
Layers stack from dsh-base up to command-line --patch overlays; later layers win per row.

The key semantic: a patch replaces a row whole rather than deep-merging keys. Your patch can override an earlier row by id, but it must restate every key the row needs — omitted keys are not kept from the old value.

Four install sources

dsh plugin add supports four sources — pick by distribution scenario:

Local path

Install from a local path
dsh plugin --profile demo add ./hello-plugin

pnpm links your checkout — edit and restart to iterate, ideal for development.

Git host

Install from GitHub
dsh plugin --profile demo add github:you/hello-plugin

Fetches sources, not built artifacts — it needs a prepare script and build permission, covered in the next step.

npm registry

Install from npm
dsh plugin add your-package

Installs a prebuilt package with no build permission needed — ideal for consuming stable releases.

Tarball

Install from a tarball
dsh plugin add ./hello-plugin-0.1.0.tgz

The tarball comes from pnpm pack — also a prebuilt artifact, so no build permission is needed.

Installing from GitHub and build permission

Git fetches sources, not built artifacts, so two conditions apply. First, the package needs a prepare script that builds from source. Second, the profile’s pnpm-workspace.yaml needs an allowBuilds entry — pnpm 10 and newer refuse to run a git dependency’s prepare script until allowed:

The profile’s pnpm-workspace.yaml
allowBuilds:
  dsh-hello-plugin: true

Pin a commit at the same time so you don’t fetch different code on every install:

Git install pinned to a commit
dsh plugin --profile demo add github:you/hello-plugin#<sha>

Running from a source checkout

If you run dsh from a source checkout instead of an installed dsh (the common case for contributors), keep the plugin at the repository root and prefix every command with pnpm:

Run dsh from source
# from the root of the deepseek-harness source repository
pnpm dsh plugin --profile dev add ./hello-plugin
pnpm dsh --profile dev --dump-config
pnpm dsh --profile dev

Everything else stays identical: the bundle layout, the patch format, and the layer composition rules do not change.

Packaging and install FAQ

What happens if a package has no dsh.bundle?

It still installs, but only as a plain dependency — dsh plugin warns and activates no layer. This is exactly how libraries a plugin package imports should be shipped: imported by plugins, but contributing no config rows themselves.

Why must the patch name be the package name?

Once a bundle is installed, its files live inside the profile’s node_modules directory, and only the package name lets Node resolution find the installed code. A relative path would break as soon as the package is installed.

What if two bundles touch the same row id?

The later layer replaces the earlier row whole — there is no deep merge. Your patch can override an earlier row by id, but it must restate every key the row needs; omitted keys are not kept from the old value.

Why does installing from GitHub require allowBuilds?

Git fetches sources, not built artifacts, so the package needs a prepare script to build on the spot. pnpm 10 and newer refuse to run a git dependency’s prepare script until allowed — allowBuilds is that grant, and it amounts to permission to execute the package’s code on your machine at install time.

What is the difference between a local path install and an npm install?

A local path install uses pnpm to link your checkout — edit the plugin code and restart the profile to see changes, which suits development. npm installs the prebuilt artifact as published, with no build permission needed, which suits consuming stable releases.