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.

Why we can write this. We ran real install tests on 122 plugins in a sandbox, and we wrote our own plugin and pushed it through all four stages. Every error below is one we actually hit.

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"
  }
}
Two fields you cannot omit. 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) };
    },
  }));
}
FieldPurpose
parametersJSON Schema for the arguments — this is how the model knows how to call you
output.schemaShape of the return value; execute must match it
output.renderTurns your value into content blocks for the model
executeDoes the work. Pass exec.signal through to fetch so cancellation works

3. inject: the trap we fell into

Our actual error. The first time we tested our own plugin, the boot log said:
dsh-deepseekbase: Error: cannot get property "systemPrompt" without inject

We 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.

ServiceUsed for
toolsRegister callable tools
systemPromptAdd a section to the system prompt
fsRead and write files (needs permission)
llmCall models
shellRun 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

A real case from our test run. 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.1

Its 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

CapabilityWhat it allowsReview note
fs-readRead filesSees your source and key material
fs-writeWrite filesCan modify your project
networkHTTP requestsCan exfiltrate data
shellRun commandsFull control
credentialsRead secretsRed line
dynamic-codeGenerate code at runtimeRed line
State plainly what you do and do not do. Ours says: outbound HTTPS to deepseekbase.com only; no file reads or writes; no credential access; no shell. The whole implementation is one file with zero dependencies — five minutes to audit.

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

We run plugin tests for free. Four real stages in an isolated sandbox: install into a clean container, confirm it lists, confirm it mounts, boot the profile. The last three run with --network none. See the 122 results so far →

10. Official docs

DocCovers
cordis-primerFive core Cordis ideas
01 first pluginMinimal plugin, three shapes
03 servicesServices and inject
tool catalogFull schema of built-in tools
config catalogEvery configuration key
On attribution: this page is our own summary after reading the official docs. The code examples are ours (MIT). We do not reproduce official text — the official repository is authoritative.

Related

Plugin test results · Plugin directory · MCP Server