English | 中文
A ready-to-run, ready-to-install starter template for DeepSeek Harness (dsh) plugins. It demonstrates the most common plugin shapes in one minimal installable bundle:
- Config — a
Configinterface plus a Schemastery schema whose live fields carry.volatile(), so the Plugins page can edit them without a restart (docs) - Tool —
ctx.tools.register(defineTool(...))registers a model-callable tool with acard-tagged render intent (docs) - Events —
ctx.on/ctx.emitwith declaration merging for typed events (docs) - Service — a class-form plugin that provides a service to other plugins (docs)
- Hook — a
tools/pre-executepermission gate that denies tool calls by config (docs) - Browser half (client) —
src/client/registers browser UI on fourteen surfaces (index: docs/ui-surfaces.md): a configuration form on the Plugins page, a sidebar footer action, an input dock strip above the composer, a shell overlay, a header utility badge, input tool-row buttons (left/right), a custom command row for/dsh-demo, a General settings row, a Plugins settings tab, a settings header action, a session header action, a composer dock strip, and per-message actions on AI replies — plus apresentResultrender intent on thegreettool.
The template follows the official bundle distribution model: the package declares dsh.bundle plus cordis.patch.yml, and dsh plugin add activates it as a config layer.
dsh-plugin-template/
├── package.json # npm manifest + dsh.bundle / dsh.client declarations + prepare build script
├── tsconfig.json # strict type-check configuration (tsc --noEmit)
├── tsdown.config.ts # build config: Node library (lib/) + client bundle (lib/client.js)
├── vitest.config.ts # unit test config (node by default; specs opt into jsdom)
├── cordis.patch.yml # bundle config layer: inserts the plugin rows
├── locale/ # plugin display metadata read by the Plugins page
│ ├── en.json # meta.title / meta.description (the discovery entry)
│ └── zh.json
├── icon.svg # optional bundle card artwork
├── dev/cordis.yml # local dev overlay (points at source; use with dsh web --patch)
├── docs/
│ ├── ui-surfaces.md # where the plugin registers UI + index of every slot (bilingual: ui-surfaces.zh.md)
├── src/
│ ├── index.ts # main plugin: Config + tool + events + effect
│ ├── commands.ts # host half: demo slash commands /hello (replies world) and /dsh-demo (custom row)
│ ├── service.ts # optional example: Service provider (disabled by default)
│ ├── hook.ts # optional example: hook permission gate (disabled by default)
│ └── client/ # browser half: one module per UI surface (see docs/ui-surfaces.md)
│ ├── index.ts # client entry: inject + apply, registers the locale dictionary and styles
│ ├── constants.ts # shared NAMESPACE + LOCALE_NAMESPACE + DEMO_COMMAND_NAME
│ ├── locales.ts # typed en/zh dictionaries (all user-visible copy lives here)
│ ├── styles.ts # one injected <style> with all dtpl-* classes (theme tokens only)
│ ├── config-card.tsx # plugins.bundle.config: the configuration form on the Plugins page
│ ├── sidebar-action.tsx # sidebar.footer.action
│ ├── input-dock.tsx # conversation.input.dock
│ ├── shell-overlay.tsx # shell.overlay
│ ├── header-utilities.tsx # conversation.session.header.utilities
│ ├── input-left.tsx # conversation.input.left
│ ├── input-right.tsx # conversation.input.right
│ ├── commandview.tsx # conversation.chat.commandview
│ ├── general-item.tsx # settings.general.item
│ ├── plugins-tab.tsx # settings.plugins.tab
│ ├── settings-action.tsx # settings.action
│ ├── header-actions.tsx # conversation.session.header.actions
│ ├── composer-dock.tsx # conversation.composer.dock
│ └── assistant-actions.tsx # conversation.chat.assistant-actions
└── test/smoke.mjs # smoke test on the build output
└── tests/ # unit tests
├── host-half.spec.ts # the host half on a real cordis Context
├── slot-registration.client.spec.ts # every surface registers, and leaves with the fiber
├── config-card.client.spec.tsx # the configuration form's user-visible behavior
├── surfaces.client.spec.tsx # command row, sidebar, input, per-message button
├── locale-and-styles.client.spec.ts # dictionary and stylesheet rules
└── support/ # test doubles: slot registry, locale
From any directory, install this package (or your fork) into a dsh profile:
# local directory
dsh plugin --profile demo add /path/to/dsh-plugin-template
# or directly from GitHub (replace with your own repo after forking)
dsh plugin --profile demo add github:you/dsh-plugin-templateA GitHub install pulls source; pnpm runs prepare (i.e. tsdown) to build lib/. On pnpm ≥10 the first git-dependency prepare is refused; add the package name pnpm prints to the profile's pnpm-workspace.yaml and retry:
allowBuilds:
dsh-plugin-template: trueThis allowlist authorizes executing that package's code at install time — only allow source you trust, and prefer pinning a commit:
github:you/dsh-plugin-template#<sha>.
Verify the config layer and boot:
dsh --profile demo --dump-config # should show a "# == dsh-plugin-template" layer
dsh --profile demoNote: a custom-named profile (e.g.
demo) contains onlydsh-baseand is headless (no GUI). For the Web GUI and the configuration form below, use thewebprofile (= dsh-base+dsh-web-app) — see testing the form.
From the root of a deepseek-harness source checkout, load this repo's source directly via an overlay (no install, no build):
pnpm dsh web --patch /absolute/path/to/dsh-plugin-template/dev/cordis.ymlSet name in dev/cordis.yml to this repo's path on your machine as a file:// URL, open http://127.0.0.1:3080, and ask the model to call the greet tool. A bare absolute path fails on Windows: the Loader hands an entry's name straight to import(), and D:\… parses as protocol d:, so the row dies with ERR_UNSUPPORTED_ESM_URL_SCHEME and the plugin never loads. Produce the URL with node -e "console.log(require('node:url').pathToFileURL('<path>').href)".
A
--patchoverlay only loads the plugin's host half (module resolution cannot reach package-level declarations). To test the browser half you must install into a profile (resolved byname: dsh-plugin-template) — see the next section.
Run the checks yourself during development:
pnpm install
pnpm typecheck
pnpm test:unit
pnpm build
pnpm smoke
pnpm testtypecheck runs tsc over the source, the tests, and the build config. test:unit runs the vitest specs; smoke runs against lib/; test runs all of it in that order.
If this repo sits INSIDE a
deepseek-harnesscheckout (nested, as in the harness repo root),pnpm installis captured by the parent workspace and installs nothing here — the template is not a workspace member. Usepnpm install --ignore-workspaceso the template installs its ownnode_modulesfrom its own lockfile; or clone the template standalone.
The form renders in the browser and depends on dsh's client-modules discovering the dsh.client declaration by package name, so the package must be installed into a profile (a --patch source path won't do):
# 1. Build (produces lib/index.js + lib/client.js)
cd /path/to/dsh-plugin-template && pnpm build
# 2. Install into the web profile (= dsh-base + dsh-web-app, full GUI)
dsh plugin --profile web add /path/to/dsh-plugin-template
# 3. Boot the web GUI (`dsh web` is equivalent to `dsh --profile web`)
dsh webOpen http://127.0.0.1:3080, go to the Plugins page in the sidebar, and select Plugin Template:
- The bundle's page renders a configuration form with
greeting,maxRetries, andverbose; - Change
greetingand click Save — the deployment accepts the values and the status line reports success; - Back in a session, ask the model to call the
greettool — it uses the new greeting (the host half readsconfig.greeting.get()on every call, no restart); - The change lands in the settings document under
$DSH_HOMEand survives restarts. Reset to default clears the field so it re-inherits the value fromcordis.patch.yml.
There is no allowlist to edit and no restart step: a plugin entry whose Config has at least one .volatile() field is served automatically, and the Plugins page passes this page its form (accepted values plus a revision-fenced mutate).
After editing the client half (src/client/), rerun pnpm build and refresh the page (the client bundle's rev query cache-busts).
- Rename the package: keep
package.jsonname(npm name, e.g.dsh-my-plugin),src/index.tsname, andcordis.patch.ymlid/nameconsistent. Renaming also touches browser-half spots: the client bundleidintsdown.config.ts(__ModuleLoader__.load({ id })),NAMESPACEinsrc/client/constants.ts,dsh.client.injectinpackage.json, andNAMESPACEinsrc/client/constants.ts(the Plugins page keys off it), pluslocale/en.json. When renaming the./servicesubpath, updateexports/filestoo. - Change the
Configinterface and schema: anything two deployments should set differently must be a config field (design principles). Mark the fields a user should be able to change without a restart with.volatile(), and read them with.get()at the point of use. - Add a row to the form in
src/client/config-card.tsxfor each new editable field: a label key, a hint key, and a branch inFIELDS/buildOps/draftValue. The form is hand-written — it does not render itself from your schema. - Register your tool in
apply:ctx.tools.register(defineTool({...}));executereturns the canonical value declared byoutput.schema, andoutput.renderis the pure function for model-visible rendering whilepresentResultis the UI render intent (tool reference). - To provide capabilities to other plugins, enable
src/service.tsand uncomment its row incordis.patch.yml. - Remember to
declare module '@deepseek-ai/cordis'to mergeContext/Eventstypes — that is what keeps cross-package boundaries type-safe. Document each event's@modeand every payload@param. - To intercept tool calls or act as a permission gate, enable
src/hook.ts(uncomment itscordis.patch.ymlrow):ctx.on('tools/pre-execute', ...)returns{ kind: 'deny', reason }or callsnext()to allow (extension cookbook). - Add a locale key to
src/client/locales.tsfor every new user-visible string, in bothenandzh. Read it through thetseat the registration'slocaleoption provides; list-slot labels use a thunk (label: () => t('key')) so a locale switch needs no re-registration.
package.jsondeclaresdsh.client: { platform: "web" }+exports["./client"]— dsh's client-modules discovers it and loadslib/client.jsas a browser plugin;lib/client.jsis a lazy-CJS factory in thewindow.__ModuleLoader__.load({ id, factory })format.tsdown.config.tsreproduces it by hand; the repo's own preset lives inpackages/client/tsdown.client.tsand is not published;- the client entry (
src/client/index.ts) registers the locale dictionary and the stylesheet throughctx.effect— both dispose with the plugin — then calls oneregister*per surface; - each surface registers through
ctx.slots.inject(name, () => ctx.slots.register(...)), which waits for the owning declaration, removes the contribution when that declaration collapses, and leaves with the plugin fiber; - at runtime the browser half depends only on
react, supplied by the browser platform module table. No@deepseek-aiclient package is imported at runtime — those appear only asimport type, which is erased. Keep that discipline when editing the template.
pnpm test:unit runs five specs. They use the real cordis Context, so fibers, effects, and disposal behave as they do in a profile:
tests/host-half.spec.ts— the host half on a real assembly: the greet tool and both commands register, the greeting is read on every call rather than once at load, and the tool leaves when the fiber is disposed.tests/slot-registration.client.spec.ts— all fourteen surfaces land on declared slots, the configuration form is keyed by the package name, the command row by the command name, the Plugins tab label is a locale-following thunk, and every contribution plus the stylesheet and the dictionaries are gone after disposal.tests/config-card.client.spec.tsx— the form's user-visible behavior: the summary and page views, loading/unavailable/read-only states, which writes it submits and with which revision, clearing a field back to the deployment default, validation that blocks saving and stays reachable to assistive technology, and both save-failure paths leaving the form usable.tests/surfaces.client.spec.tsx— the command row's three states (running, succeeded, failed), the sidebar button keeping an accessible name in rail mode, the input control being an explicit non-submit button, and the per-message button addressing its message without printing its id.tests/locale-and-styles.client.spec.ts— the two client rules that rot quietly: everyt('…')key exists in the dictionaries, and the stylesheet carries no literal colors and no font weight above 500.
test/smoke.mjs is separate and runs against lib/: it checks that the built artifact loads, the tool and commands work, and the permission gate denies and delegates. pnpm test runs typecheck, units, build, and smoke in that order.
The test doubles in tests/support/ stand in for the harness client services. They cannot be the real ones: the published client entries are browser bundles that call window.__ModuleLoader__.load(...) at import time, so materializing one inside a Node test would pull a second React into the process. The doubles are cordis services, so they keep the property that matters here — contributions are mounted on the caller's fiber and are torn down with it — and they enforce that an undeclared slot throws. Their comments say exactly what they do and do not model.
- npm:
pnpm publish(filesalready includes the build output, the patch, the metadata, and the icon) - tarball:
pnpm pack, thendsh plugin --profile demo add ./dsh-plugin-template-0.2.0.tgz - git:
dsh plugin add github:you/dsh-plugin-template(combined with theallowBuildsstep above)
- Live configuration forms: adding-a-settings-card.md
- Plugin development intro: basic/index.md
- Plugin config: basic/config.md
- Tool development: basic/tool.md
- Packaging & installation: basic/publish.md
- Plugins & lifecycle: framework/index.md
- Services & dependencies: framework/service.md
- Event system: framework/events.md
- Client UI slots: subsystems/slots.md
- Cordis tutorial: cordis-tutorial