How to Write a DeepSeek Harness Plugin
A practical guide to the dsh plugin system: rows and patch layers, bundles and profiles, where skills are discovered, how to add an MCP server, and how to pass it credentials.
DeepSeek open sourced their harness yesterday. The pitch is one sentence: everything is a plugin. Models, tools, skills, sessions, sandboxes, storage, the agent loop and the UI are all plugin rows you can swap.
I packaged Upstash’s skills and the Upstash MCP server as a bundle for it:
The plugin system is good. Finding out how it works was the hard part, so this post is the map I wish I had: what a plugin is, where skills come from, how to add an MCP server, and the one problem that forced me to write code instead of YAML.
What a DeepSeek Harness plugin actually is
dsh boots by composing a list of rows. A row is an id, a module specifier and
some config:
- id: mcp-upstash
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: upstash
transport: stdio
Adding a plugin always means getting a row into that list. A patch is a file containing row edits, and the composed list is built by stacking patch layers in a fixed order (CLI reference):
| Layer | Comes from | Owned by |
|---|---|---|
| 1 | each bundle’s cordis.patch.yml | package authors |
| 2 | $DSH_HOME/profiles/<name>/cordis.patch.yml | the user, per profile |
| 3 | $DSH_HOME/cordis.patch.yml | the user, machine wide |
| 4 | each --patch <file> | the user, per launch |
$DSH_HOME defaults to ~/.dsh. Later layers win per row, and a patch replaces a
row’s whole config instead of merging keys. dsh --dump-config prints the composed
tree with a comment naming the file that supplied each row, which is the fastest way
to check your work.
Underneath is Cordis,
a dependency injection kernel. A plugin is a module exporting apply(ctx, config),
optionally name, inject and a schema, as described in the
first plugin tutorial.
Skills need no plugin at all
This was my first surprise. The base composition already mounts
dsh-skill-filesystem,
which scans these roots, lower rank winning a duplicate name:
| Rank | Source | Path |
|---|---|---|
| 100 | project-dsh | <projectRoot>/.dsh/skills |
| 200 | project-agents | <projectRoot>/.agents/skills |
| 300 | custom | Config.customSkillDirs |
| 400 | user-dsh | ~/.dsh/skills |
| 500 | user-agents | ~/.agents/skills |
Rank 200 and 500 are the plain
Agent Skills convention, so anything already published
that way works in dsh with zero DeepSeek specific packaging. The
skills CLI installs to
.agents/skills/ in a project by default. For the global root, pass --global with
an agent whose global path is ~/.agents/skills:
# project scope, lands in .agents/skills/
npx skills add upstash/skills
# global scope, lands in ~/.agents/skills/
npx skills add upstash/skills --global --agent cline
The format is the ordinary one: <name>/SKILL.md or a flat <name>.md, kebab case
name, a required description, optional whenToUse. Two details worth knowing.
Discovery is one level deep
on purpose, so a nested skill tree is invisible. And the frontmatter keys
disable-model-invocation and user-invocable are read in exactly that kebab case:
a camelCase spelling drops the whole skill with a warning rather than ignoring the
field.
The model only ever sees names and descriptions in an <available_skills> block,
then loads a body on demand with a skill({ name }) call. Familiar progressive
disclosure, and the catalog hot refreshes when files change.
Shipping a bundle straight from a GitHub repo
Skills come for free, but an MCP server does not. It needs a plugin row, and its credentials need somewhere to live. Packaging both together turns that into a single install command.
A bundle is an npm package that declares dsh.bundle in its package.json. A
profile is a directory under $DSH_HOME/profiles/<name> listing the bundles it
composes. You author bundles, users boot profiles. The
publish tutorial
covers both.
{
"name": "upstash-skills",
"dsh": {
"bundle": { "patch": "./.dsh-plugin/cordis.patch.yml" }
}
}
The patch path is a plain
join(packageDir, declared)
with no validation, so everything except that one manifest key can live in a
subdirectory. That let me keep the layout the repo already used for its Claude Code,
Cursor and Codex manifests.
dsh plugin is a wrapper around pnpm with the profile directory as cwd, so a git
spec just works and nothing has to be published to npm:
dsh plugin --profile web add github:upstash/skills
dsh web
Two things I checked before trusting this. Dot directories survive a git install
(pnpm add github:upstash/skills really does deliver .dsh-plugin/), and a package
with no build step avoids the allowBuilds prompt: pnpm 10 refuses to run a git
dependency’s prepare script until the user allowlists it, which is a fine gate but
a bad first impression. If your bundle is TypeScript, publish to npm or ship a
tarball instead.
Adding the MCP server
The bridge is
@deepseek-ai/dsh-mcp-client:
one plugin instance per server, stdio or streamable HTTP. Tools land on ctx.tools
as mcp__<serverName>__<rawName>, the same server qualified shape Claude Code and
Codex use, so the model sees mcp__upstash__redis_database_create_new.
Nothing is enabled by default, and the reasoning is stated plainly in the CLI reference: “no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.” (Source)
For a user who just wants a server, a patch row in ~/.dsh/cordis.patch.yml is
enough, and the
memory examples
in the repo are a good template. My problem started one step later, with the API key.
The credential problem
The Upstash MCP server needs an email and an API key. dsh has two places a secret
can live, and they behave differently:
$DSH_HOME/.envis an ordinary environment layer.loadLayeredEnv“materializes accepted values intoprocess.envfor Loader expressions and third-party libraries.” (Source)$DSH_HOME/.credentials.yamlis the managed store behindctx.credentials. The provider “never loads it into the process environment, unlike$DSH_HOME/.env.” (Source)
That matters because YAML config can read environment variables through a !!js
tag, and the implementation is
eight lines:
export const evaluate = new Function('ctx', 'expr', `
with (ctx) {
return eval(expr)
}
`);
So an expression gets ctx properties and Node globals, and nothing else. No
require, no import.meta, and no way to await anything. ctx.credentials.resolve()
is async, so YAML cannot reach the managed store at all.
That left two options:
- Put the key in
~/.dsh/.envand read it with!!js process.env.UPSTASH_API_KEY. Simple, one row, no code. The downside is that the file is hand edited only. Nothing in the harness writes it for you. - Put the key in
.credentials.yamland mount the MCP client from code. More moving parts, butctx.credentialshas aset()method, so a plugin can store the key for the user.
I took the second. Not needing to tell people “open this file in your editor” was worth the extra thirty lines.
One detail applies either way. Child processes start from a scrubbed environment:
SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
strips anything credential shaped before a spawn. UPSTASH_API_KEY matches on KEY,
so it has to be handed to the child explicitly through the row’s env.
Mounting the MCP server from code
ctx.plugin(plugin, config) mounts another plugin and returns a
fiber
with a dispose(). That is all you need to mount, tear down and remount a server as
credentials change:
export const inject = ['skills', 'credentials', 'commands'];
export function apply(ctx) {
ctx.plugin(SkillFilesystem, {
providerName: 'upstash',
includeDefaultRoots: false,
customSkillDirs: [SKILLS_DIR],
});
let fiber;
const remount = async () => {
await fiber?.dispose();
const mail = await ctx.credentials.resolve('UPSTASH_EMAIL');
const key = await ctx.credentials.resolve('UPSTASH_API_KEY');
if (!mail || !key) return;
fiber = ctx.plugin(McpClient, {
serverName: 'upstash',
transport: 'stdio',
command: 'npx',
args: ['-y', '@upstash/mcp-server@latest'],
env: {
UPSTASH_EMAIL: mail.value,
UPSTASH_API_KEY: key.value,
},
});
};
ctx.on('credentials/updated', () => void remount());
void remount();
}
The skills half is the same trick in reverse. A second copy of the filesystem
provider with includeDefaultRoots: false sees only the roots I give it, which is
exactly how you ship skills inside a package: resolve the directory from
import.meta.url in JS, since YAML cannot name it.
The real version serializes those remounts through a promise chain. Storing two
credentials fires
credentials/updated
twice, and two overlapping remounts would leave an orphaned server process behind.
A slash command to store the key
My first idea was a settings page card. The
settings UI package
rules that out today, in its own words: “a plugin distributed outside this repository
cannot surface its own configuration here without a change in
packages/host/apiproxy.” Exposure is a host allowlist, not a plugin declaration.
So the way in is a human command:
ctx.commands.register({
name: 'upstash-login',
description: 'Store Upstash credentials',
input: { hint: '<email> <api-key>' },
recordInput: false,
handler: async ({ rawInput }) => {
const [mail, key] = rawInput.trim().split(/\s+/);
await ctx.credentials.set('UPSTASH_EMAIL', mail);
await ctx.credentials.set('UPSTASH_API_KEY', key);
return { kind: 'success', text: 'Stored.' };
},
});
Two properties make this safe enough for a secret. recordInput: false means the
command/run session event omits the arguments, so the key never lands in the
session log, and command results are rendered by the adapter without entering model
history.
One trap: set() rejects while a read only source shadows the reference. If the user
already exported UPSTASH_API_KEY in the shell that launched dsh, the inherited
environment wins and a write would silently have no effect. The provider refuses
instead, so call describe() first and say so.
No support for agent-plugins.org
I expected Agent Plugins to work here, since the Upstash
repo already ships that manifest. It does not. I cloned the harness and grepped it:
zero hits for agent-plugins, plugin.schema.json or anything related. The only
occurrences of mcpServers are in the ACP bridge, where a client supplied list is
explicitly rejected.
The two models are different shapes. Agent Plugins 1.0.0 is a declarative package:
plugin.json at the root, an optional mcp.json, a skills/ directory, and exactly
two component types. A dsh bundle is an npm package whose patch can mount arbitrary
code, up to replacing the agent loop or the UI. plugin.json has no field that could
express “swap the session store.”
The compatibility that does exist is at the component level, and it covers most of
what you want: the Agent Skills format, the .agents roots,
AGENTS.md
instruction loading, and the mcp__<server>__<tool> naming. A dsh bundle that reads
installed Agent Plugins packages and emits one mcp-client row per mcpServers entry
would be maybe fifty lines. Nobody has written it yet.
The result
The bundle is merged. Two files and one
package.json key:
dsh plugin --profile web add github:upstash/skills
dsh web
Then, inside a session:
/upstash-login YOUR_EMAIL YOUR_API_KEY
That gives you the Upstash skills and the Upstash MCP server, with credentials in the managed store rather than a hand edited file.
My take: the plugin system is well designed and the package READMEs are unusually honest, each one carrying a “known limitations and deferred work” section that saved me real time. What is missing is the path from “I want to add a thing” to the three files that do it. Almost everything above came from reading package source rather than docs. Shipping the harness with skills for working on it, the way Vercel did with Eve, would have closed most of that gap.