DSH plugin development
A DSH plugin is a cordis plugin: an ES module exporting apply(ctx, config), packaged with a
dsh.bundle.patch declaration so dsh plugin add promotes it into a profile's layer stack.
When to use
- Adding a tool the model can call (
ctx.tools.register(defineTool(...))). - Hooking harness events (
tools/pre-execute,agent/pre-step,session/event, ...). - Providing a service (
class X extends Service), an LLM adapter, or a user-facing surface (ctx.settings.registerfor a settings card,ctx.webServer.registerfor a route). - Diagnosing a plugin that installs but never loads, or whose config is ignored.
Do not use for editing DSH itself, or for ~/.dsh/profiles/<p>/cordis.yml (always []; edit
cordis.patch.yml instead).
Environment (verified)
DSH="$HOME/.dsh/profiles/node_modules/@deepseek-ai/dsh/lib/bin.js" # no PATH shim; $HOME, never ~
# profiles: $HOME/.dsh/profiles/web, $HOME/.dsh/profiles/headless
# installed: cordis 4.0.1, schemastery 3.18.1, dsh-tools/dsh-llm/dsh-session 0.1.1-rc.2
Reference implementation, known-good and installable: /Users/charlesqin/orca/projects/DSH-Project/dsh-pi-review.
Quick start
SKILL=/Users/charlesqin/orca/projects/DSH-Project/.claude/skills/dsh-plugin
# $DSH is the handle defined under "Environment" above
# 1. scaffold (writes package.json, tsconfig.json, cordis.patch.yml, src/, test/)
node $SKILL/scripts/scaffold.mjs dsh-hello --dir $HOME/code --profile web
# 2. install deps + build (dist/ MUST exist before install — a link: install runs no prepare)
cd $HOME/code/dsh-hello && npm install && npm run build
# 3. verify before touching the profile
node $SKILL/scripts/verify.mjs $HOME/code/dsh-hello --profile web
# 4. install into the profile (-w is MANDATORY for `add`)
cd $HOME/code && $DSH plugin --profile web add -w ./dsh-hello
# 5. confirm it composed into the tree
$DSH --profile web --dump-config | tail -20 # look for "# == dsh-hello"
After every source edit: npm run build, then restart the profile. The profile holds a
symlink, so a stale dist/ is one cause of "my change did nothing" — but module HMR is off in
both stock profiles (root: []), so a rebuilt dist/ is never hot-reloaded even when it is
fresh. Only ~/.dsh/profiles/<p>/cordis.patch.yml and ~/.dsh/cordis.patch.yml reload live.
See references/install-and-verify.md §5.
Uninstall: $DSH plugin --profile web remove dsh-hello (no -w needed; remove by package name).
The plugin contract
src/index.ts — the whole minimal plugin:
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
export const name = 'dsh-hello'
// Every entry in `inject` is REQUIRED and blocking. apply() re-runs whenever one
// of these services is replaced. There is no required/optional object split in
// cordis 4.0.1 — for an optional dep, omit it here and use ctx.get('name').
export const inject = ['tools']
// Config must be BOTH a TS interface and a same-named runtime Schema (declaration
// merging). A plain object here does not work — cordis needs a Standard Schema.
export interface Config {
greeting: string
loud: boolean
}
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default('Hello').description('Prefix for the reply.'),
loud: Schema.boolean().default(false),
})
export function apply(ctx: Context, config: Config) {
// Anything registered through ctx (listeners, tools, timers) is disposed
// automatically. Only external resources need an explicit effect:
ctx.effect(() => () => { /* kill child processes, close handles */ })
}
cordis.patch.yml — name must be the installed package name, id is the stable handle later
patch layers target:
- insert:
- id: hello
name: dsh-hello
config:
greeting: Hello
package.json essentials: "type": "module", main/types pointing at dist/,
"files": ["dist", "cordis.patch.yml"], and "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }.
Without dsh.bundle.patch the package installs as a plain dependency and activates nothing.
Adding a tool
import { defineTool, type ToolRunContext } from '@deepseek-ai/dsh-tools'
ctx.tools.register(defineTool({
name: 'hello_greet',
description: 'Greet a person. Use when the user asks for a greeting.',
// `parameters` is an IMPLICIT open object: write the property map directly,
// never wrap it in { type: 'object', properties: ... }.
parameters: {
who: {
type: 'string',
required: true, // per-property annotation; there is NO `required: [...]` array
description: 'Name to greet.',
},
style: {
type: 'string',
enum: ['plain', 'loud'], // inline literal array infers 'plain' | 'loud' (no `as const` needed)
description: 'Delivery style.',
},
opts: {
type: 'object',
additionalProperties: false, // MANDATORY on every explicit object node
properties: { times: { type: 'number' } },
},
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
// exec.signal is a REQUIRED AbortSignal — thread it into every subprocess/fetch.
async execute(args, exec: ToolRunContext) {
exec.signal.throwIfAborted()
const text = `Hello, ${args.who}!`
return args.style === 'loud' ? text.toUpperCase() : text
},
}))
register() returns the exact disposer; cordis calls it on unload. The reserved name run_code and
duplicate names within one layer both throw. See references/tools.md for the full schema DSL,
presentCall/presentResult views, finalizeContent, timeoutMs, and testing a tool directly.
Hooking an event
ctx.on('agent/session-start', ({ source }) => { // emit mode: observe, return nothing
ctx.logger('dsh-hello').info('session start: %s', source)
})
ctx.on('tools/pre-execute', async (exec, next) => { // waterfall mode: MUST return next()
if (exec.name === 'bash') return { kind: 'deny', reason: 'blocked by dsh-hello' }
return next() // ...unless deliberately vetoing
})
Event names are exact and typed — agent/step does not exist (agent/pre-step does). Listeners are
fiber-owned: unload removes them, no manual disposer.
Providing a service
import { Context, Service } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context { metrics: MetricsService } // types ctx.metrics for consumers
}
export default class MetricsService extends Service {
constructor(ctx: Context, public config: Config) {
super(ctx, 'metrics') // registers ctx.metrics now; unregisters with the fiber
}
record(event: string, value: number) { /* ... */ }
}
Consumers write export const inject = ['metrics']. Ship the declare module block in your published
.d.ts or ctx.metrics stays untyped downstream. Depth for both: references/services-and-events.md.
Verification ladder
Run in order; each rung catches a class the next cannot. node $SKILL/scripts/verify.mjs <plugin-dir> --profile web runs rungs 1-5 for you.
| # | Check | Command |
|---|---|---|
| 1 | Typecheck | npx tsc -p tsconfig.json --noEmit |
| 2 | Build | npm run build |
| 3 | Unit tests (import from ../dist/*.js, so they prove the build loads) |
node --test test/*.test.mjs |
| 4 | Load-check — stub Context, real defineTool() + real apply(), zero boot cost |
node $SKILL/scripts/verify.mjs <plugin-dir> |
| 5 | Composition — is it in the tree with the right id/name/config? | $DSH --profile web --dump-config | tail -20 |
| 6 | Boot | cd /tmp && $DSH --profile web --help then timeout 20 $DSH --profile web --port 0 --no-open |
| 7 | Functional — the model can see and call it | $DSH plugin --profile headless add -w ./dsh-my-plugin then cd <test dir> && $DSH --profile headless "use the <tool_name> tool to <trivial task>" |
Rung 4 is the cheapest positive proof: defineTool() throws on a malformed schema node, and
mod.Config({}) shows the exact defaults the harness hands to apply(). Rung 6 is only negative
evidence that apply() ran — the default log level hides plugin info lines. Rung 7 is the only
end-to-end proof: pass if the tool's rendered output appears in the printed reply. It spends real
provider tokens (trivial task, once, at the end), and it requires the plugin to inject nothing
web-only — injecting a service headless never provides means apply() never runs there, which
just looks like "the model ignored my tool". Web-only fallback: references/install-and-verify.md §4.4.
Non-negotiable rules
- Pin devDeps to the profile's versions.
npm view @deepseek-ai/dsh-tools dist-tags→latest: 0.0.1-rc.1, but the profile runs0.1.1-rc.2(nexttag)."*"or a barenpm igives you a different API. Pin^0.1.1-rc.2/ cordis^4.0.1/ schemastery^3.18.1. Harness packages go inpeerDependenciesas"*"anddevDependenciespinned. dsh plugin addneeds-w. The profile dir is a pnpm workspace root; without-wpnpm failsERR_PNPM_ADDING_TO_ROOTand dsh exits 1.removedoes not need it.- Rebuild before every install and after every source edit. A directory spec installs as
link:(symlink), soprepare/buildnever runs. additionalProperties: booleanis mandatory on every explicit{type:'object'}schema node — but never write it at theparametersroot, which is an implicit open object.- Requiredness is a per-property
required: trueannotation. No JSON-Schemarequired: [...]array. It is legal only on a key ofparametersor of an object node'sproperties— not onitems,oneOfbranches, or a schema root. - Use
typealiases, notinterface, for anything returned through a{type:'json'}output node or assigned tometa. Aninterfacehas no implicit index signature and is not assignable toJsonValue. Also: no optional members — anundefinedproperty fails a JSON node; usenull. ctx.effect(() => () => cleanup())for external resources only (child processes, sockets, watchers). Everything registered viactxis disposed automatically. Disposers run in reverse order.- No
dshon PATH. Invoke$HOME/.dsh/profiles/node_modules/@deepseek-ai/dsh/lib/bin.jsdirectly, via the$DSHhandle. NeverDSH="~/..."— a tilde inside double quotes is not expanded. KeepnodeOUT of the handle:DSH="node /path"then$DSH --versionbreaks under zsh, which does not word-split an unquoted parameter.lib/bin.jsis the package's declaredbin, so it is executable on its own. export const reusable/disposable/usingdo not exist in cordis 4.0.1. Neither doesService.start()/stop(), nor a thirdimmediatearg tosuper(ctx, name). The complete module export set isname,Config,inject,provide,intercept,apply."type": "module"andfiles: ["dist", "cordis.patch.yml"]. Omitting the patch fromfilesmakes a published install a silent non-bundle.
Deeper reference
| File | Read it when |
|---|---|
references/plugin-anatomy.md |
Choosing function/object/class form, Config schemas and Schemastery factories, inject semantics, ctx.effect contract, fiber lifecycle, declaration merging for Context/Events. |
references/tools.md |
Writing any tool: full ValueSchemaSpec DSL, DefineToolOptions signatures, type inference rules, ToolRunContext, result/view types, error classes, unit-testing a ToolDefinition. |
references/services-and-events.md |
Hooking harness events (dispatch modes, waterfall/next() semantics, the full verified event catalog); providing/consuming a service (ctx.tools, ctx.llm, ctx.agents, ctx.fs, ctx.systemPrompt, ... and which are web-profile-only); writing an LLM adapter; and user-facing surfaces — settings cards and ctx.webServer routes. |
references/install-and-verify.md |
Packaging details, package.json/tsconfig fields, the patch grammar and layer order, --dump-config/--dump-default-config, git installs and the onlyBuiltDependencies build-script gate (dsh's error names a stale key), uninstall and disable-without-uninstall. |
references/pitfalls.md |
A plugin installs but never loads, config is ignored, a patch does nothing, types don't compile, or subprocesses are orphaned. Check here first when something is silently wrong. |