DeepSeek Harness plugin development: forms, inject, cleanup
A DSH plugin development spec boils down to three rules: register every capability through ctx, declare every required dependency in inject, and hand every manually released resource to ctx.effect. These are not style preferences - they are the preconditions of the framework's lifecycle machinery. Break them and you usually get no immediate error, only ghost registrations and leaks when the plugin unloads, a provider is replaced, or HMR kicks in. The sections below turn the official docs into a checklist you can verify item by item, and the same items apply to any DeepSeek Harness plugin.
DSH plugin forms and naming rules: function, object, class
A DSH plugin can take three forms, and the function form is enough for most cases. The official docs put it plainly: "Function form is sufficient in most cases" - use the class form only when the plugin must provide a service to others (Source).
The function form uses named exports and is the most common one:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-plugin'
export function apply(ctx: Context) {
// Register capabilities here.
}
The object form uses export default, collecting name, inject and apply into one object:
export default {
name: 'my-plugin',
inject: ['tools'],
apply(ctx: Context) {
// ...
},
}
The class form is also default-exported and extends Service:
import { Service, type Context } from '@deepseek-ai/cordis'
export default class MetricsService extends Service {
static inject = ['llm']
constructor(ctx: Context) {
super(ctx, 'metrics')
}
}
Spec rule: named exports for the function form and export default for the object and class forms must not be mixed. Do not ship both an export default and a named apply - the framework reads one form, and the other half of your declarations is silently ignored.
Naming follows two more rules: name is the plugin's unique identity inside the config tree, while the class form hands the service name to super(). For the function and object forms the exported name is what cordis.yml entries reference and what failure reports point at; for the class form the service name is the first argument of super(ctx, 'metrics'), and consumers reach it as ctx.metrics.
Two rules that are easy to miss:
- Keep
nameunique and readable. Avoid generic words such aspluginormain, or you cannot trace a failure back to its source in the config tree. - The service name defines the consumer's access path. Renaming it after release is a breaking change, so settle it before you publish.
DSH plugin dependency rules: inject versus ctx.get
Declare required dependencies in inject and query optional ones with ctx.get() at the call site - never use an optional lookup to bypass inject. The official docs draw the line clearly (Source):
// Required: the plugin does not load while the service is absent.
export const inject = ['tools']
// Optional: omit inject and query with ctx.get() at the use site.
export function apply(ctx: Context) {
const metrics = ctx.get('metrics')
metrics?.record('plugin_loaded', 1)
}
The rule in words: if the plugin cannot do its job without the service, that dependency is required and belongs in inject; if the service merely adds an extra step, it is optional and belongs in a ctx.get() check. Writing a required dependency as ctx.get() lets the plugin reach ACTIVE in a half-broken state - which is exactly where most "it installed but does nothing" reports come from.
DSH plugin lifecycle and cleanup rules: the Fiber state machine
Every DSH plugin owns a Fiber scope, and its state is the single authoritative signal when you debug a plugin. The official state machine (Source):
PENDING → LOADING → ACTIVE
↘ FAILED
ACTIVE → UNLOADING → DISPOSED
| State | Meaning | What to do |
|---|---|---|
| PENDING | Declared, but required dependencies are not ready | Check the services listed in inject |
| LOADING | Dependencies ready, apply is running | Do not block inside apply |
| ACTIVE | Plugin is running | Normal state |
| FAILED | apply threw | Read the stack trace in the startup log |
| UNLOADING / DISPOSED | Unloading / fully unloaded | Confirm cleanup was handed back to the framework |
Dependency-driven loading is part of the spec. A plugin that declares inject waits for every required service; if a required service disappears - for example when its provider is replaced - the plugin is unloaded automatically and loaded again once the service returns. Your apply must therefore be repeatable: never put a once-only global side effect inside it.
Cleanup is the other half of the same lifecycle machinery: anything registered through ctx is tracked and undone when the plugin unloads, so you never call removeListener or clearInterval by hand. The official docs list four auto-cleaned registrations:
ctx.on(event, handler)- event listenersctx.tools.register(tool)- tool registrationsctx.llm.registerAdapter(names, adapter)- LLM adapter registrationsctx.effect(() => cleanup)- custom resources
export function apply(ctx: Context) {
ctx.on('some-event', handler)
ctx.effect(() => {
const connection = createConnection()
return () => connection.close()
})
}
The rule most people miss: disposers start in reverse registration order, but asynchronous disposers run concurrently and are not guaranteed to finish one by one. So cleanup steps with ordering dependencies - "close the stream, then close the connection" - must live inside a single ctx.effect() disposer that owns the serial waiting. Splitting them across two ctx.effect calls loses the ordering guarantee.
DSH plugin service rules: use the class form to provide capabilities
When a plugin provides a capability to other plugins it must use the class form and do three things: extend Service, call super(ctx, '<name>') in the constructor, and declare its own dependencies with static inject. A service is still a plugin, so it is default-exported too (Source):
import { Service, type Context } from '@deepseek-ai/cordis'
export default class MetricsService extends Service {
static inject = ['llm']
constructor(ctx: Context) {
super(ctx, 'metrics')
}
record(event: string, value: number) {
// Public service method.
}
}
Consumers declare export const inject = ['metrics'] and then call ctx.metrics.record(...). The companion rule is declaration merging: add the new service to the Context interface so consumers get type hints. Declaration merging only supplies types - providing and consuming the service at runtime still happen through the class plugin and inject respectively.
Service-name collisions and isolation are a separate concern: cordis.yml supports isolate so different plugin groups each see their own instance of the same service. Use it when you build grouped plugins instead of renaming services to dodge conflicts.
DSH plugin packaging and release rules
A distributed DSH plugin ships as a bundle: package.json declares dsh.bundle.patch, and cordis.patch.yml inserts the plugin's own entry into the config tree. The minimal layout:
hello-plugin/
├── package.json # declares dsh.bundle
├── cordis.patch.yml # the config layer this bundle contributes
└── index.js # the plugin module referenced by the patch row
{
"name": "dsh-hello-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"files": ["index.js", "cordis.patch.yml"],
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
Install into a profile to verify, in two steps:
- Install into the profile - run
dsh plugin --profile demo add ./hello-plugin. Expect: the package lands in the profile dependencies and the patch fromdsh.bundleis appended todsh.profile.bundles. - Check the config layer - run
dsh --profile demo --dump-config. Expect: your plugin entry shows up in the printed config tree, confirming the patch is in force.
Spec rule: declare files and ship cordis.patch.yml with the package. Publishing only index.js leaves the installer without a config layer, so the plugin installs but never gets mounted. To see real directory layouts and bundle declarations from community plugins, compare against similar entries in DSH Plugin Hub.
DSH plugin development spec checklist
Verify each item before you release:
- Form matches export style: function form uses
export const name+export function apply; object and class forms useexport default; never mix. nameis unique and readable; service names are settled before release, not generic words.- Every required dependency is in
inject; only optional ones usectx.get(). applyis repeatable and holds no once-only global side effects.- Manually released resources go through
ctx.effect(), with ordering-dependent cleanup merged into one disposer. - Providing a capability uses the class form:
Service+super(ctx, '<name>')+static inject, plus declaration merging for types. - Configuration is validated with a
Configschema that fails loud on bad input instead of silently falling back (see DSH plugin configuration). - The bundle declaration is complete:
dsh.bundle.patchplusfilesincludingcordis.patch.yml.
For the minimal path to a first plugin see DSH plugin development; if a plugin installs but never activates, work backwards from the Fiber state in DSH plugin not taking effect; for duplicate service registration errors see DSH plugin service already registered.
Sources: official "Plugins and lifecycle", official "Your first plugin", official "Services and dependencies"
FAQ
A DSH plugin development spec comes down to three rules: **register every capability through ctx, declare every required dependency in inject, and hand every manually released resource to ctx.effect**. Follow all three and plugin unload, provider replacement and hot reload all stay clean. Break them and you usually get no immediate error - just ghost registrations or leaked resources in those same three scenarios (Source: official "Plugins and lifecycle").
In a DSH plugin the form and the export style must match: **the function form uses named exports (export const name plus export function apply), while the object and class forms use export default**. The official docs state that "Function form is sufficient in most cases"; use the class form only when the plugin provides a service to others. Never mix them - do not ship both an export default and a named apply (Source: official "Your first plugin").
A DSH plugin stuck in PENDING means it is "declared, but its required dependencies are not ready" - which is exactly what a missing inject service looks like. The Fiber state machine runs PENDING → LOADING → ACTIVE; a plugin with inject waits for every required service before apply runs. If a required service disappears, for example when its provider is replaced, the plugin is unloaded automatically and loaded again once the service returns (Source: official "Plugins and lifecycle").
A DSH plugin should give cleanup back to the framework: anything registered through ctx is undone when the plugin unloads, including ctx.on, ctx.tools.register, ctx.llm.registerAdapter and ctx.effect. The subtle part is that **disposers start in reverse registration order but asynchronous disposers run concurrently and are not guaranteed to finish one by one** - so cleanup steps with ordering dependencies must live inside a single ctx.effect() disposer, which owns the serial waiting (Source: official "Plugins and lifecycle").
A DSH plugin must use the class form **when it provides a capability to other plugins**: the class extends Service, calls super(ctx, 'metrics') in its constructor to name the service, and uses static inject for its own dependencies. The class is still a plugin, so it is default-exported. Consumers declare export const inject = ['metrics'] and then call its public methods as ctx.metrics (Source: official "Services and dependencies").
Related Terms
- Fiber
- Fiber is the scope unit of a DSH plugin and the unit Cordis uses to manage plugin lifecycle. Its state machine is PENDING → LOADING → ACTIVE / FAILED plus ACTIVE → UNLOADING → DISPOSED; it stays PENDING while dependencies are missing and enters FAILED on an exception.— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/framework/index.md
- inject
- inject is the export that declares a DSH plugin's required services. A plugin with inject waits for every required service before apply runs; if a service disappears the plugin unloads automatically and reloads when the service returns. Optional dependencies skip inject and use ctx.get() at the call site instead.— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/framework/service.md
- ctx.effect
- ctx.effect is how a DSH plugin registers a resource that needs explicit release: the function you pass returns a disposer that runs when the plugin unloads. Cleanup steps with ordering dependencies must be merged into one ctx.effect, because separate disposers run concurrently.— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/framework/index.md
- Service
- Service is the base class a DSH plugin extends to expose a capability to other plugins, implemented in the class form. The constructor calls super(ctx, '<name>') to name the service, and other plugins reach its public methods as ctx.<name> after declaring it in inject.— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/framework/service.md