TL;DR
DeepSeek Harness plugins are Cordis modules that contribute capabilities through an apply(ctx) function, declared dependencies, services, events, and reversible effects. In the dsh-v0.2.1-alpha.1 source examined for this guide, a deployable extension also belongs to a bundle, while a runnable installation belongs to a profile composed from ordered patch layers. A correct plugin therefore needs lifecycle tests, an explicit compatibility target, an inspectable effective tree, and a rollback path.
Table of Contents
- The smallest useful plugin
- Services dependencies and lifecycle
- Choosing a service event or effect
- Put policy at the correct tool seam
- Profiles bundles patches and installation
- Testing plugin contracts
- FAQ
- Related resources
The smallest useful plugin
A DeepSeek Harness plugin is a TypeScript module mounted by Cordis. The official first-plugin tutorial uses an exported name and apply(ctx) function. That shape proves only that Loader can import and mount the module; it does not provide a tool, authorization, persistence, distribution metadata, or cleanup policy.
Treat local development and persistent installation as separate stages:
| Stage | Artifact | What it proves |
|---|---|---|
| Scratch development | TypeScript module plus --patch overlay |
The module mounts in one disposable checkout |
| Distribution | npm package with dsh.bundle.patch metadata |
The package contributes a named configuration layer |
| Installation | Named profile with a bundle list and profile patch | The chosen runtime can resolve and compose the package |
| Runtime verification | Effective tree, activation state, and contract tests | The intended capability is active and behaves correctly |
The official tutorial loads a local plugin through an absolute-path cordis.yml overlay:
- insert:
- id: workspace-notice
name: "/absolute/path/to/deepseek-harness/scratch-plugin/src/workspace-notice.ts"
Start the Web profile with the overlay, then inspect the composed tree:
pnpm dsh --profile web --patch ./scratch-plugin/cordis.yml
pnpm dsh --profile web --patch ./scratch-plugin/cordis.yml --dump-config
Use this workflow only in a throwaway workspace first. A patch can alter the active plugin tree, and the module executes in the host process. File sandbox mode does not turn untrusted plugin code into a sandboxed dependency.
Services dependencies and lifecycle
Cordis services provide named capabilities on the context. A plugin that needs ctx.tools, for example, declares tools in inject; Cordis keeps the plugin pending until the provider exists. If a required service later disappears, Cordis unloads the dependent plugin and mounts it again only after the service returns. Dependency injection is therefore a live lifecycle contract, not a one-time boot assertion.
The pattern has three important boundaries:
| Concern | Correct contract | Unsafe shortcut |
|---|---|---|
| Dependency | Declare inject for consumed services |
Reach into an optional service and assume it exists |
| Lifecycle | Register through ctx.on() or ctx.effect() |
Keep unmanaged timers, sockets, or listeners |
| Capability | Depend on an interface such as ctx.tools |
Import and mutate a concrete internal implementation |
Cordis registrations are reversible effects. Event listeners and supported registrations are removed when the plugin unloads. A timer, network client, watcher, or other unmanaged resource must be acquired inside ctx.effect() and released by its disposer. When teardown order matters, keep dependent cleanup in one effect because separate asynchronous disposers can run concurrently.
Never use a timer as a substitute for durable scheduling or recovery. If the process restarts, the interval disappears. Scheduled work needs a persisted job contract and an explicit recovery model.
Choosing a service event or effect
The DSH architecture distinguishes direct capabilities from lifecycle seams. Use a service method when your plugin calls a capability directly. Use an event when it needs to observe, transform, or govern work moving through the runtime.
The current extension cookbook and tool subsystem define a stricter sequence:
tools/pre-execute: extensible allow, deny, or approval policy before dispatch.ctx.tools.guard(): monotonic denial for an invariant that later listeners must not undo.tools/execute: wrap dispatch lifetime for cancellation, deadlines, retries, or metrics.tools/post-execute: inspect, block, or replace a result before final materialization.tools/result: observe the frozen authoritative outcome without changing it.session/event: project logged events into UI, telemetry, or an external protocol.
These are not interchangeable. A reorderable pre-execute listener is not a final security boundary; a monotonic guard cannot force-allow a call another guard denied; replacing display content does not automatically hide the programmatic value; and a tools/result observer is too late to prevent an effect.
Put Policy at the Correct Tool Seam
Select the seam from the invariant you need to enforce. A plugin that adds a cooperative approval policy belongs at tools/pre-execute and must delegate with next() when it does not own the final decision. A tenant boundary that must survive listener reordering belongs in a monotonic guard. A timeout wrapper belongs around tools/execute, while final metrics belong on tools/result.
None of these framework seams proves that a write is safe. Production policy must bind an authenticated user and tenant to the exact resource, parameters, schema digest, time window, policy revision, and approval artifact. The downstream service must enforce its own authority as well. For the wider problem, see Human-in-the-Loop and Approval Gate.
Profiles Bundles Patches and Installation
Current DSH separates the package you distribute from the application composition a user runs:
| Concept | Current role | Operational implication |
|---|---|---|
| Bundle | npm package declaring one or more ordered patch files | Version and review it as executable supply-chain input |
| Profile | Runnable composition under the Harness home | Keep user and environment choices outside the bundle |
| Profile patch | User-owned layer applied after bundle layers | Later rows win, and a row's config is replaced rather than deep-merged |
| Home patch | Machine-wide preferences shared by profiles | Review its impact on every profile |
--patch overlay |
Highest-priority invocation layer | Use for controlled experiments, not undocumented production drift |
Persistent installation uses dsh plugin --profile <name> add <spec>. The manager first checks bundle metadata and runtime peer compatibility; package build-script approval is separate because approved scripts run with host-user authority. A version exemption is exact to one plugin and one runtime version, is not inherited by upgrades, and should be treated as an explicit data-loss or crash risk acceptance.
Before rollout:
- Pin the DSH revision and every external plugin dependency.
- Inspect source, dependency scripts, requested host capabilities, and bundle patch.
- Keep the reviewed patch and expected effective tree in version control.
- Install into a disposable profile and check whether activation is
applied,restart-required, or failed. - Run allow, deny, cancellation, unload, restart, and data-compatibility tests.
- Roll out with a narrow capability set, scoped credentials, and a tested removal path.
Do not promise compatibility based on a package name. Public DSH APIs remain pre-stable, and Session data has stronger compatibility obligations than ordinary runtime interfaces.
Testing plugin contracts
Test the plugin's observable contract rather than only its happy path. The following matrix is a useful minimum:
| Test | Assertion |
|---|---|
| Mount | Declared service dependencies are available before apply() runs |
| Unload | Listeners, timers, and clients are disposed |
| Allowed action | A permitted tool reaches execution and produces a result |
| Denied action | A prohibited tool does not reach execution |
| Approval expiry | A stale approval cannot authorize changed parameters |
| Cancellation | Cancellation does not leave unmanaged work running |
| Effective tree | Dumped configuration matches the reviewed bundle and profile layers |
| Persistence | Old fixtures either migrate faithfully or fail closed |
| Upgrade | The same suite passes on the intended pinned DSH revision |
For a tool plugin, add an integration test with a fake or sandboxed downstream system. Do not validate a write policy by pointing it at a shared repository or real customer data. The Agent Harness evaluation guide covers fault injection and release gates that complement framework tests.
The following Go gate validates a captured rollout manifest before a plugin is promoted. It does not load the plugin; it prevents an operator from mistaking “installed” for “compatible and active.”
package main
import (
"errors"
"fmt"
)
type Manifest struct {
RuntimeVersion string
PluginVersion string
PeerCompatible bool
BundleDeclared bool
ApplicationState string
TestsPassed bool
RollbackTested bool
}
func validate(manifest Manifest) error {
if manifest.RuntimeVersion == "" || manifest.PluginVersion == "" {
return errors.New("runtime and plugin versions must be pinned")
}
if !manifest.PeerCompatible {
return errors.New("plugin peer range rejects this runtime")
}
if !manifest.BundleDeclared {
return errors.New("package has no dsh bundle patch")
}
if manifest.ApplicationState != "applied" {
return fmt.Errorf("plugin is not active: %s", manifest.ApplicationState)
}
if !manifest.TestsPassed || !manifest.RollbackTested {
return errors.New("contract tests and rollback rehearsal are required")
}
return nil
}
func main() {
ready := Manifest{
RuntimeVersion: "dsh-v0.2.1-alpha.1",
PluginVersion: "plugin-7",
PeerCompatible: true,
BundleDeclared: true,
ApplicationState: "applied",
TestsPassed: true,
RollbackTested: true,
}
restartRequired := ready
restartRequired.ApplicationState = "restart-required"
fmt.Printf("ready=%t\n", validate(ready) == nil)
fmt.Printf("restart_required_rejected=%t\n", validate(restartRequired) != nil)
}
FAQ
Should every DeepSeek Harness feature become a plugin?
No. A capability should become a plugin when it has an independent lifecycle, configuration surface, dependency boundary, or replacement need. Splitting every small helper into a plugin can obscure control flow and complicate compatibility testing.
Can I use private APIs from the DSH repository?
You can technically import source internals from a checkout, but you should not treat them as stable extension contracts. Prefer generated subsystem references and documented service/event seams, pin the source revision, and add an upgrade test whenever an internal dependency is unavoidable.
How should a plugin store state?
Choose state by its required semantics. Runtime-only caches can be in memory. Model-visible or replay-relevant facts need durable session events. External side effects need their own application-owned operation and reconciliation record.
Can a plugin add an MCP server?
The DSH extension map describes MCP as a plugin per server that discovers tools and registers them with ctx.tools. Discovery does not authorize tool use: validate the server, tool metadata, tenant scope, arguments, result limits, and downstream effects.
What should be versioned with a plugin?
Version the plugin package or commit, DSH revision, profile template, bundle order, configuration patch, service assumptions, model route, policy revision, test suite, Session fixtures, and every external protocol or tool schema that changes behavior.