A dsh plugin is a module with named exports: name, inject (the service keys it needs), Config (a schemastery schema), and apply(ctx, config). Register a tool with ctx.tools.register(defineTool({...})). Mount it by inserting a row with an id and your package name into cordis.patch.yml, then confirm with dsh --profile web --dump-config.
Almost everything written about DeepSeek Harness so far describes it from the outside. This page is from the inside: the actual module contract, taken from the source of a tool that ships with the harness.
The reference throughout is @deepseek-ai/dsh-tool-todo — the todo_write tool. It is small enough
to read in full and exercises nearly every part of the plugin surface.
What a plugin is
A Cordis plugin is an object implementing the Service interface, in one of two forms: a function
with optional inject and apply(ctx) properties, or a class extending Service whose lifecycle
Cordis manages.
In practice, the harness's own tools are neither — they are modules with named exports:
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'tool-todo'
export const inject = ['tools']
export interface Config {
allowParallelInProgress: boolean
}
export const Config: z<Config> = z.object({
allowParallelInProgress: z.boolean().required(),
})
export function apply(ctx: Context, config: Config): void {
// register things here
}Four named exports: name, inject, Config (twice — the type and the schema), and apply.
inject: depend on seams, not implementations
export const inject = ['tools']This declares the service keys your plugin needs. The framework waits until those services exist before activating you, so load order is expressed through service requirements rather than manual boot sequencing. You never write a boot order.
The architectural rule behind it: extension plugins depend on Service Definitions, never concrete
providers. ctx.tools is a seam — a definition, its providers, and its consumers considered
together. You inject the key; whoever provides it is not your problem.
The seams available include ctx.tools, ctx.llm, ctx.shell, ctx.sandbox, ctx.approval,
ctx.fs, ctx.web, ctx.subagents, ctx.jobs, ctx.terminals, ctx.storage, ctx.credentials,
ctx.settings, ctx.sessionQuery, ctx.systemPrompt, ctx.codeRuntime, ctx.lsp and
ctx.workflowEngine, among others.
Optional dependencies
For a seam that may or may not be composed, inject inside apply instead:
export function apply(ctx: Context, config: Config): void {
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register({ /* … */ })
})
}The callback runs only if that service is present. In tool-todo this is how the projection unit
activates when a projection registry exists, leaving headless assemblies without the seam
unaffected. This is the right pattern for "nice to have" capabilities — a hard inject at module
level would prevent your plugin from activating at all.
Config
Two exports with the same name: a TypeScript interface and a matching schemastery schema.
export interface Config {
allowParallelInProgress: boolean
}
export const Config: z<Config> = z.object({
allowParallelInProgress: z.boolean().required(),
})The schema is what validates the config: block a deployment writes in YAML. Note
z.boolean().required() — tool-todo makes this a required deployment choice rather than
defaulting it, because the correct answer depends on whether the deployment runs work concurrently.
Requiring a decision is a legitimate design move when there is no safe default.
Registering a tool
export function apply(ctx: Context, config: Config): void {
ctx.tools.register(defineTool({
name: 'todo_write',
description: describe(config.allowParallelInProgress),
parameters: {
todos: {
type: 'array',
required: true,
description: 'The COMPLETE task list, replacing any previous list.',
items: {
type: 'object',
additionalProperties: false,
properties: {
content: { type: 'string', required: true, description: 'What the task is.' },
status: {
type: 'string',
required: true,
enum: ['pending', 'in_progress', 'completed'],
description: 'pending | in_progress | completed',
},
},
},
},
},
output: {
schema: { /* same shape language, describing the return value */ },
render: (_args, value) => [{
type: 'text',
text: `Updated todo list: ${value.counts.pending} pending…`,
}],
},
execute(args, exec) {
// …
},
presentCall: args => ({
card: 'generic',
title: 'Update todo list',
kind: 'other',
rawInput: args.todos,
}),
}))
}Six parts worth understanding.
parameters is not raw JSON Schema. Required-ness is expressed inline per property
(required: true), not as a sibling required: [] array. Getting this wrong is the most likely
first mistake if you are used to writing JSON Schema by hand.
additionalProperties: false appears on every object. tool-todo's source explains why: "the
logged snapshot must equal what the model believes it wrote, so a nested/extended item shape fails
loud at the schema boundary instead of silently flattening." In an event-sourced system, silently
accepting extra keys corrupts the log.
output.schema describes the return value in the same shape language. Your tool's output is
typed, not free-form.
output.render turns that value into what the human sees. Separating the machine value from the
rendered text means the model gets structured data while the UI gets a sentence.
execute(args, exec) is the body. args is already schema-checked by the registry.
presentCall describes how the call itself is displayed before it runs — the card the user sees
when approving or watching.
Validate what the schema cannot
The registry enforces the schema. Everything else is yours:
execute(args, exec) {
const todos = toTodoList(args.todos, allowParallel) // trims, dedupes, enforces one-active
if (!exec.agent) {
throw new Error('todo_write requires an owning agent session')
}
exec.agent.session.append('todo/write', { todos })
return Promise.resolve({ todos, counts: /* … */ })
}tool-todo validates non-empty trimmed content, rejects duplicates, and enforces the
at-most-one-in_progress rule — none of which a schema can express. Throwing is the correct
failure mode; the error reaches the model.
Note if (!exec.agent). A tool can be invoked without an owning agent session, and this tool's
state is per-agent, so it rejects rather than silently no-ops. Decide deliberately what your tool
does when there is no agent.
Appending session events
exec.agent.session.append('todo/write', { todos })This is the harness's event-sourced core. Everything the model sees is recorded in an append-only session log, and resume, fork, search and replay all operate on that stream. Your tool does not mutate state — it appends an event, and readers fold the events into state.
tool-todo's fold, registered on the optional projection seam, is worth reading as a model:
apply: (state, event) => {
if (event.type === 'todo/write') return event.data.todos
if (event.type === 'turn/start') return null
return state
},Last-write-wins, cleared at the start of the next turn, and — importantly — every other event returns the same state reference, so unrelated events cause no re-render.
package.json
{
"name": "@deepseek-ai/dsh-tool-todo",
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./package.json": "./package.json"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-tools": "…",
"@deepseek-ai/cordis": "…"
}
}The important choice: the harness packages and Cordis itself are peerDependencies, not
dependencies. Your plugin must run against the host's copy of the framework, not bundle its own.
Getting this wrong gives you two Cordis instances and services that mysteriously fail to resolve.
type: "module" — ESM only.
Mounting it
Writing the plugin does not activate it. Something must insert a row. Here is the shape, from
dsh-base's own patch file:
- insert:
- id: session-title
name: '@deepseek-ai/dsh-session-title'
config:
fallbackMaxWords: 5
fallbackMaxBytes: 40
maxTitleBytes: 80A row is an id (how later layers address it), a name (the package), and an optional config
(validated by your schemastery schema). Put yours in your profile's patch file:
# $DSH_HOME/profiles/web/cordis.patch.yml
- insert:
- id: my-tool
name: 'your-plugin-package'
config:
someOption: trueInstall the package into the profile, then verify:
dsh plugin --profile web add your-plugin-package
dsh --profile web --dump-configIf your row is not in that output, the plugin is not running — see a plugin installs but never loads.
What we do not know yet
@deepseek-ai/* is the official scope and you cannot publish into it. The naming convention for
third-party plugin packages is not documented in the repository as of this writing. Cordis's
registry supports manifest.ecosystem for multi-level ecosystems and manifest.exports for
publishing several plugins from one package, so a convention very likely exists or is coming — but
we are not going to invent one and have you rename later.
If you are building early, the safe move is a name you control that reads clearly, and a readiness to republish under the convention once it lands.
Frequently asked
Can I use a default export?
No. The harness's own example bundle notes that it exposes named exports only, because Loader default unwrapping would discard the plugin's Config schema. The repository logs this as postmortem 0001.
What is the difference between inject and importing a package?
inject declares a service key your plugin needs — the framework waits until that service exists before activating you. Importing binds you to a concrete implementation. Extension plugins depend on service definitions, never on concrete providers.
Which package name should a third-party plugin use?
@deepseek-ai/* is the official scope and you cannot publish there. The convention for third-party plugin names is not documented in the repository as of this writing, so we are not going to guess at one.
Does my plugin need to be a class?
No. A Cordis plugin is either a function with optional inject and apply properties, or a Service subclass. A module exporting name/inject/Config/apply is the shape the harness's own tools use.