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

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:

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

sh
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.

flowchart TD A["Need new behavior"] --> B{"Direct capability call?"} B -->|Yes| C["Consume a documented service"] B -->|No| D{"Lifecycle policy or observation?"} D -->|Yes| E["Use a documented event seam"] D -->|No| F["Define a narrow service interface"] C --> G["Register reversible effect"] E --> G F --> G

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:

  1. Pin the DSH revision and every external plugin dependency.
  2. Inspect source, dependency scripts, requested host capabilities, and bundle patch.
  3. Keep the reviewed patch and expected effective tree in version control.
  4. Install into a disposable profile and check whether activation is applied, restart-required, or failed.
  5. Run allow, deny, cancellation, unload, restart, and data-compatibility tests.
  6. 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.”

go
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.