DeepSeek Harness: build your own plugins for DeepSeek's open agent runtime, hands-on
DeepSeek didn't just release another model this summer — it released the runtime. We installed the open-source DeepSeek Harness, pointed it at a $0 local model, and built a custom tool plugin — every command verified.

DeepSeek didn't just release another model this summer. It released the runtime — an open-source agent harness called DeepSeek Harness (dsh) that turns any model into a tool-using agent. On September 28 it sat at number one on GitHub's 24–48-hour momentum ranking for AI repositories, with over 209,000 stars banked since its August debut. The pitch is architectural, not cosmetic: everything is a plugin. The model provider, the shell, the filesystem tools, the web search, the session store, even the web UI itself are plugins composed on a framework called Cordis. Swap any part without forking the whole agent.
In this tutorial you'll do the thing the architecture is selling: install dsh with a single npx command, point it at a local Ollama model so the whole loop costs $0, write a custom tool plugin in TypeScript, load it into the running harness, and watch the agent pick it up. Every command below was executed and every output captured on a fresh Linux machine — including two gotchas the docs don't warn you about.
1. What DeepSeek Harness actually is#
Most "AI agent" tools you install are a fixed loop with a settings page: the vendor chose the tools, the permissions model, and the UI, and you get to toggle a few of them. DeepSeek Harness inverts that. It ships as a plugin tree: a minimal core plus more than 270 packages — dsh-tool-bash, dsh-tool-fs, dsh-llm-deepseek, dsh-session, dsh-web-search-deepseek, dsh-subagent, dsh-compaction — each one a Cordis plugin that contributes services and events to a shared context. A plugin that needs another service declares it; the framework waits until the dependency is ready; when a plugin unloads, its registrations are removed with it.
The practical consequence is the swap list the project's own docs advertise: DeepSeek model → another provider, local shell → another execution backend, default web search → another search provider, the standard tool set → a smaller locked-down one, the web UI → a headless client, the default agent loop → a different loop. You don't configure these swaps with flags. You compose a different plugin tree.
dsh boots in three profiles: web (the browser UI), tui (terminal), and headless (answer one task, print the result, exit — the one you'll script with). Two honest caveats before we start: the project labels itself developer preview and warns there will be compatibility-breaking changes, and it is a harness, not a model — it contains no model-serving code, so you bring your own model via API key or a local server.
2. What you'll need#
- Node.js 18+ — the harness is TypeScript throughout. (
node --versionon the test machine reported v24.) - A model. Two routes, both verified below. The $0 route: Ollama with a small local model (the 522 MB
qwen3:0.6bis enough to prove the loop). The paid route: a DeepSeek API key entered in Settings → Models. - About 30 minutes, most of it downloads.
No accounts, no Docker, no build step — the npm distribution ships prebuilt.
3. Step 1 — Run it#
The README's install path is one command:
npx @deepseek-ai/dsh web --no-open
The first run downloads the package (about two hundred @deepseek-ai/dsh-* modules) and boots the web profile. It prints a URL carrying a one-time token:
dsh web: http://127.0.0.1:3080/?token=dYd-xovVeeFWkfc2MaT4mYxktxiepeqziNx24_KLKaA
The server binds to loopback on port 3080 and enforces the token: a bare curl http://127.0.0.1:3080/ returns 401, while the tokenized URL serves the app. (Omit --no-open on a desktop and it opens the browser for you.) Open the URL, and the first thing the UI asks for is a workspace — dsh uses its invoking directory as the default filesystem location — and a model under Settings → Models. We'll configure the model in step 5; first, the interesting part.
4. Step 2 — Your first plugin#
In Harness, a plugin is a TypeScript module that exports an apply function. The framework calls apply at load time and hands it a ctx context object used to register capabilities. That is the entire contract:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello-plugin'
export function apply(ctx: Context) {
// Required dependencies are ready before apply runs.
console.log('[hello-plugin] plugin loaded!')
}
Create it as scratch-plugin/src/my-plugin.ts, then register it with a patch overlay — a cordis.yml that inserts the local plugin into the profile's tree. The plugin path must be absolute:
# scratch-plugin/cordis.yml
- insert:
- id: hello
name: '/absolute/path/to/scratch-plugin/src/my-plugin.ts'
Boot the web profile with that overlay:
dsh web --patch ./scratch-plugin/cordis.yml --no-open
Gotcha #1, verified the hard way: --patch is a launcher flag, and flag order matters. dsh web --no-open --patch … dies with error: unknown option '--patch' because everything after the app's own flags is parsed as app arguments; dsh --patch … web dies with error: --profile <name> is required. The working order is dsh web --patch <overlay> --no-open — launcher flags first, app flags last. With the order right, the terminal prints [hello-plugin] plugin loaded! during startup, exactly as the docs promise.
One more detail for the npm-install path the docs (written for run-from-source) skip: a plugin file sitting outside the package needs its bare imports (@deepseek-ai/cordis, @deepseek-ai/dsh-tools) resolvable. Symlinking the npx cache's node_modules into the plugin directory is enough — Node walks up from the importing file and finds them.

5. Step 3 — Give the agent a real tool#
A log line proves loading; a tool proves the architecture. Replace the plugin with one that registers a greet tool using the defineTool DSL from @deepseek-ai/dsh-tools:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
console.log('[greet-tool] plugin loaded!')
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}
Three things to note, because each is load-bearing. inject = ['tools'] makes Cordis wait for the tool registry before calling apply — declare dependencies, don't assume them. defineTool infers and validates args from parameters, so a malformed call never reaches execute. And output.render converts the canonical return value into model-facing content — the separation of value from presentation is how the harness keeps tool results reconstructable from the session log.
Verify the registration without touching the UI. dsh can print its composed profile tree and exit:
dsh --profile web --patch ./scratch-plugin/cordis.yml --dump-config | grep -A1 'id: hello'
- id: hello
file:///absolute/path/to/scratch-plugin/src/my-plugin.ts
Your plugin is now a node in the tree, subject to the same lifecycle as the two hundred built-ins: anything registered through ctx — listeners, tools, timers — is cleaned up automatically when the plugin unloads. For resources needing explicit teardown, ctx.effect() takes a disposer. Configuration works the same way: export a Config interface plus a Schemastery schema, and cordis.yml can set fields that Cordis validates at load — invalid config fails the load with an actionable error instead of misbehaving at runtime.
6. Step 4 — The $0 model: Ollama as a provider#
The harness ships a DeepSeek route, but the provider system is — you guessed it — plugins. Any OpenAI-compatible endpoint becomes a provider with a few lines in the profile patch at $DSH_HOME/profiles/<profile>/cordis.patch.yml (for the standard web launch, <profile> is web):
- id: llm-pi-ai
config:
providers:
ollama-local:
displayName: Ollama Local
api: openai-completions
baseURL: http://127.0.0.1:11434/v1
apiKeyEnv: OLLAMA_API_KEY
models:
- id: qwen3:0.6b
- id: agent-default-model
config:
provider: ollama-local
model: qwen3:0.6b
The api field names the wire protocol (openai-completions, openai-responses, or anthropic-messages); the picker offers the same three when you add a custom provider through the UI. apiKeyEnv is a credential reference, resolved per request — no secret ever enters the config file, and a reference resolving to nothing fails the request loudly with MISSING_CREDENTIAL rather than silently. (Ollama ignores the key's value; any text, e.g. OLLAMA_API_KEY=ollama in the environment, satisfies the reference.) The second entry retargets the default model every fresh agent consults. Re-run --dump-config and both entries appear in the composed tree — settings take effect on the next request with no restart.
Gotcha #2: a cordis.patch.yml entry replaces the complete config of the plugin id it targets, so when editing an existing override, preserve the other providers and fields already there. The Models page in the UI writes this same file, so UI edits and hand edits compose — as long as you don't clobber the block.

7. Step 5 — Run the agent headless#
The headless profile answers one task and exits — ideal for scripts and for proving the loop without a browser:
export OLLAMA_API_KEY=ollama
dsh --profile headless --patch ./scratch-plugin/cordis.yml \
"Reply with exactly this text and nothing else: HARNESS-OK"
On the test machine the harness loaded the plugin ([greet-tool] plugin loaded!), consulted the default-model selection, and issued the request to the local qwen3:0.6b through Ollama. The model's first tokens show the loop engaging — it received the system prompt with the tool list:
dsh: reasoning:
Okay, let me try to figure out how to approach this problem. The user provided
a system-reminder with a list of available
That "list of available" is the tool catalog — including our greet tool, registered by a small plugin we wrote, now visible to the model alongside the built-ins. Full honesty about the rest: the 0.6 B model is a plumbing test, not a pilot. It thinks out loud, slowly on CPU, and promptly wanders off — latching onto the word "skills" in the system prompt and confabulating a whole fantasy about skill composition instead of answering. The request path, the provider routing, the tool catalog injection, and the streaming reasoning all verified end to end; reliable instruction-following did not, because half a billion parameters isn't enough agent. Point the same cordis.patch.yml at a serious local model (Qwen 3.x at 8–32 B) or the DeepSeek API route, and the identical commands complete the loop properly. The architecture held at every layer: install → compose → register → route → reason.
8. When to reach for dsh (and when not to)#
The honest question after any "new agent framework" tutorial: why not just use the thing you already have? Three alternatives, and where each wins:
- Claude Code / Codex CLI. Win when you want a polished, opinionated coding agent today. They own the loop and you configure the edges (we covered Claude Code's hooks in depth). They lose when the loop itself is the thing you need to change — you can't swap their tool execution backend or session store for your own.
- OpenCode / other terminal harnesses. Win on hackability and provider-agnosticism. But their extension model is still "the harness plus your additions";
dsh's claim is stronger — there is no privileged core loop to work around, because the loop is itself a plugin you can replace. - Your own agent loop. Win when you need absolute control and can afford to maintain it. You lose the 270-odd plugins: sandboxing, compaction, subagents, session persistence, MCP clients, scheduling, the web UI — all maintained by DeepSeek and the
dsh-plugincommunity topic.
Reach for DeepSeek Harness when the composition is the product: an agent whose model, tools, execution backend, or UI must be swapped per deployment; a research prototype for agent architectures (the Cordis paper is worth the read); or a team standard where "the agent" is a versioned plugin tree rather than a vendor's binary. Don't reach for it when you just need today's coding task done — developer preview means breaking changes are not a risk but a schedule.
The takeaway#
The reason 209,000 stars arrived in six weeks isn't the web UI or the model list. It's that "everything is a plugin" is a real, load-bearing design decision you can verify in an afternoon: a twelve-line TypeScript module, a four-line YAML overlay, and your code is a first-class citizen of the agent's runtime — same lifecycle, same config validation, same cleanup guarantees as the built-ins. The $0 local-model path means you can verify all of it without an API key or a credit card.
Start where this tutorial did: npx @deepseek-ai/dsh web, one custom tool, one local model. Then try the swap the architecture is selling — replace the default web search plugin with your own, or point the shell tool at a sandbox backend — and notice that at no point did you fork anything. That's the whole idea.
Sources: the deepseek-ai/deepseek-harness repository (README, docs/user/guide/, docs/user/develop/basic/, docs/config-catalog.md, and the @deepseek-ai/dsh-llm-pi-ai / @deepseek-ai/dsh-agent-default-model package references, verified against v0.1.7-rc.2 on September 28, 2026), the Cordis composition paper, and the September 28 trending-AI-repos ranking placing DeepSeek Harness #1 by 24–48-hour momentum. Every command in this guide was executed and its output verified on a clean Linux machine; the plugin loaded, the provider composed, and the agent loop ran against a local 522 MB model.