DeepSeek Harness plugin development: forms, inject, cleanup

Plugin DevelopmentPublished 2026-09-12Author: DeepSeek Plugin Market
DSH pluginDeepSeek Harnessplugin specCordislifecycle
DSH plugin development spec: three plugin forms, name and inject rules, auto-cleaned registrations, service classes and a pre-release checklist.

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:

ts
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:

ts
export default {
  name: 'my-plugin',
  inject: ['tools'],
  apply(ctx: Context) {
    // ...
  },
}

The class form is also default-exported and extends Service:

ts
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 name unique and readable. Avoid generic words such as plugin or main, 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):

ts
// 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
StateMeaningWhat to do
PENDINGDeclared, but required dependencies are not readyCheck the services listed in inject
LOADINGDependencies ready, apply is runningDo not block inside apply
ACTIVEPlugin is runningNormal state
FAILEDapply threwRead the stack trace in the startup log
UNLOADING / DISPOSEDUnloading / fully unloadedConfirm 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 listeners
  • ctx.tools.register(tool) - tool registrations
  • ctx.llm.registerAdapter(names, adapter) - LLM adapter registrations
  • ctx.effect(() => cleanup) - custom resources
ts
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):

ts
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
json
{
  "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:

  1. Install into the profile - run dsh plugin --profile demo add ./hello-plugin. Expect: the package lands in the profile dependencies and the patch from dsh.bundle is appended to dsh.profile.bundles.
  2. 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:

  1. Form matches export style: function form uses export const name + export function apply; object and class forms use export default; never mix.
  2. name is unique and readable; service names are settled before release, not generic words.
  3. Every required dependency is in inject; only optional ones use ctx.get().
  4. apply is repeatable and holds no once-only global side effects.
  5. Manually released resources go through ctx.effect(), with ordering-dependent cleanup merged into one disposer.
  6. Providing a capability uses the class form: Service + super(ctx, '<name>') + static inject, plus declaration merging for types.
  7. Configuration is validated with a Config schema that fails loud on bad input instead of silently falling back (see DSH plugin configuration).
  8. The bundle declaration is complete: dsh.bundle.patch plus files including cordis.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

What hard rules does a DSH plugin development spec actually require?

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").

Should a DSH plugin use export default or named exports, and why does it matter?

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").

Why does my DSH plugin stay in PENDING and never activate, and is inject to blame?

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").

My DSH plugin leaks resources on unload - how should cleanup be written?

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").

When must a DSH plugin use the class form, and how are the service name and inject written?

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

Sources