DSH Plugin Development Guide
The official docs explain the concepts, but there is a gap between "understood" and "it installs". This page is the path we actually walked building our plugin test bench and our own plugin — including the bugs.
1. A minimal plugin: three files
my-plugin/ ├── package.json # declares this is a dsh plugin and where the patch lives ├── cordis.patch.yml # tells the loader what to mount └── lib/index.js # the actual code
package.json
{
"name": "dsh-my-plugin",
"version": "0.1.0",
"type": "module",
"main": "lib/index.js",
"files": ["lib", "cordis.patch.yml"],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
},
"engines": {
"dsh": ">=0.2.0-rc.2"
}
}
dsh.bundle.patch points the loader at your patch file;
engines.dsh declares the compatible DSH range.
Of the 122 plugins we tested, 8 could not install because of a hard-coded engines.dsh.cordis.patch.yml
- insert:
- id: dsh-my-plugin
name: dsh-my-plugin
Three lines. id is the name in the composed config tree (must be unique);
name is the module specifier — an npm package name or a relative path.
lib/index.js
export const name = "dsh-my-plugin";
export function apply(ctx) {
console.log("hello from my plugin");
}
That is all. A named apply export; Cordis calls it on load and
passes ctx — everything the plugin contributes is registered through ctx.
2. Registering a tool
Most plugins add tools to the agent. The API is
ctx.tools.register(defineTool({...})).
import { defineTool } from "@deepseek-ai/dsh-tools";
export const inject = ["tools"]; // see section 3 — this is the line people forget
export function apply(ctx) {
ctx.tools.register(defineTool({
name: "my_lookup",
description: "Look up something and return text.",
parameters: {
query: { type: "string", required: true, description: "What to look up." },
limit: { type: "number", description: "Max results. Defaults to 5." },
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: { text: { type: "string", required: true } },
},
render: (_args, value) => [{ type: "text", text: value.text || "" }],
},
isConcurrencySafe: () => true,
async execute(args, exec) {
const res = await fetch("https://example.com/api?q=" + encodeURIComponent(args.query));
const data = await res.json();
return { text: JSON.stringify(data, null, 2) };
},
}));
}
| Field | Purpose |
|---|---|
parameters | JSON Schema for the arguments — this is how the model knows how to call you |
output.schema | Shape of the return value; execute must match it |
output.render | Turns your value into content blocks for the model |
execute | Does the work. Pass exec.signal through to fetch so cancellation works |
3. inject: the trap we fell into
dsh-deepseekbase: Error: cannot get property "systemPrompt" without injectWe called
ctx.systemPrompt.section(...) but declared only
inject = ["tools"]. Changing it to
["tools", "systemPrompt"] fixed it.
Rule: if you use ctx.something in apply, declare
"something" in inject.
export const inject = ["tools", "systemPrompt"];
export function apply(ctx) {
ctx.systemPrompt.section({ // needs "systemPrompt"
name: "tool:mine",
order: 120,
text: "Use my_lookup when asked about X.",
});
ctx.tools.register(defineTool({ // needs "tools"
/* ... */
}));
}
Cordis holds your plugin in PENDING until every injected service exists, then calls
apply. That solves load ordering — but it also means a service you forgot to
inject throws the moment you touch it.
| Service | Used for |
|---|---|
tools | Register callable tools |
systemPrompt | Add a section to the system prompt |
fs | Read and write files (needs permission) |
llm | Call models |
shell | Run commands (high risk) |
4. Three debugging moves
1. Inspect the composed tree
dsh --profile web --dump-config | grep -A2 my-plugin
If your plugin is not here, cordis.patch.yml is wrong or the package does not
resolve.
2. Look for "did not activate"
If boot prints dsh: warning: 1 entry did not activate, the plugin mounted but
threw inside apply. Scroll up for the Error.
3. Reinstall clean
dsh plugin --profile web remove dsh-my-plugin dsh plugin --profile web add ./my-plugin
Most "it does not take effect" cases are a stale installed copy.
5. engines.dsh: the second trap
dsh-ops@0.4.4 declares "engines.dsh": ">=0.2.0-rc.2 <0.2.2-0",
which excludes 0.2.2-rc.1 — so installation is refused outright:dsh: installation rejected: Plugin dsh-ops@0.4.4 is incompatible with dsh 0.2.2-rc.1Its CI was green and its unit tests passed, but users cannot install it. 8 of 122 plugins failed this way.
Prefer a lower bound only:
"engines": {
"dsh": ">=0.2.0"
}
6. Configuration
- insert:
- id: dsh-my-plugin
name: dsh-my-plugin
config:
endpoint: "https://example.com/api"
limit: 5
export function apply(ctx, config) {
const endpoint = (config && config.endpoint) || "https://default.example.com";
const limit = (config && config.limit) || 5;
}
Always default everything. A user may install with no config at all.
7. Permissions
| Capability | What it allows | Review note |
|---|---|---|
fs-read | Read files | Sees your source and key material |
fs-write | Write files | Can modify your project |
network | HTTP requests | Can exfiltrate data |
shell | Run commands | Full control |
credentials | Read secrets | Red line |
dynamic-code | Generate code at runtime | Red line |
8. A complete example
dsh-deepseekbase follows everything above and is fully open source: 34-line
package.json, 3-line patch, ~250-line lib/index.js, four tools, zero
runtime dependencies, plus a GitHub Actions workflow that runs the same four-stage check on
every push.
9. Get it verified
--network none.
See the 122 results so far →10. Official docs
| Doc | Covers |
|---|---|
| cordis-primer | Five core Cordis ideas |
| 01 first plugin | Minimal plugin, three shapes |
| 03 services | Services and inject |
| tool catalog | Full schema of built-in tools |
| config catalog | Every configuration key |