DeepSeek Harness 8: Services, Events & Bundling for Distributable Plugins

This tutorial completes the wechat-publisher plugin series by extracting publish logic into a reusable Service, using events for decoupled notifications, and packaging the plugin as an installable Bundle for distribution via dsh plugin commands.

AI Code to Success
AI Code to Success
AI Code to Success
DeepSeek Harness 8: Services, Events & Bundling for Distributable Plugins

Introduction

After three practical episodes, the wechat-publisher plugin can load, register the publish_article tool, and read and validate configuration. However, all logic is crammed into the apply function: publishing, tool registration, and configuration reading are mixed together. Adding features like publish history, retry on failure, or allowing other plugins to call the publish capability would turn the single file into a mess. This final episode introduces Service and Event to enable inter-plugin capability sharing and loose coupling, then packages the plugin as a distributable Bundle so users only need dsh plugin to add it to their Profile and start using it.

1. Lifecycle: The Plugin State Machine

Before discussing services, understand the Fiber state machine every plugin goes through:

Fiber state machine diagram
Fiber state machine diagram

States: PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED. A plugin that declares inject stays in PENDING until all dependent services are ready, then enters LOADING. This is why writing inject: ['tools'] in episode 5 guarantees ctx.tools is available — the framework ensures it. If a depended service disappears, the plugin automatically unloads ( ACTIVE → DISPOSED) and reloads when the service recovers.

2. Service: Extracting Publish Logic into WechatPublisherService

A service is a capability a plugin exposes to other plugins. tools, llm, agents are all services — they are mounted on ctx and any plugin can use them.

We extract the publish logic into a Service. Create scratch-plugin/src/service.ts:

import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context {
    wechatPublisher: WechatPublisherService
  }
  interface Events {
    'wechat-publisher/published': (event: {
      articleId: string
      title: string
      publishedAt: string
    }) => void
  }
}

export class WechatPublisherService extends Service {
  static inject = ['tools']

  private mode: 'browser' | 'api'
  private author: string

  constructor(ctx: Context, config: { mode: 'browser' | 'api'; defaultAuthor: string }) {
    super(ctx, 'wechatPublisher')
    this.mode = config.mode
    this.author = config.defaultAuthor
  }

  // Public method: other plugins can call ctx.wechatPublisher.publish(...)
  async publish(markdownPath: string, title: string) {
    console.log(`[wechat-publisher] publishing 《${title}》 via ${this.mode}`)
    // Real implementation: browser mode uses CDP, api mode uses official API
    return { success: true, articleId: `mock-${Date.now()}` }
  }
}

Key points: extends Service + super(ctx, 'wechatPublisher') registers the service on ctx.wechatPublisher.

To keep the example consistent with the previous wechat-publisher entry, the Service is constructed directly in apply(), and the plugin's own inject: ['tools'] guarantees ctx.tools is ready. If the Service itself were a standalone plugin managed by Cordis, it could declare its own dependencies via static inject. declare module is TypeScript declaration merging, giving ctx.wechatPublisher correct types.

Relative imports must include the .ts extension (e.g., from './service.ts'): the DSH execution environment follows Node ESM resolution rules; omitting the extension may cause ERR_MODULE_NOT_FOUND.

Like Service, Event types should also be declared so event names and payload structures enter the TypeScript type system, avoiding "convention-based" string and object communication.

Then install the service in the plugin entry apply:

import type { Context } from '@deepseek-ai/cordis'
import { WechatPublisherService } from './service.ts'

export const name = 'wechat-publisher'
export const inject = ['tools']

export function apply(ctx: Context, config: Config) {
  // Construction registers: super(ctx, 'wechatPublisher') already mounts the service on ctx;
  // no manual cleanup needed — service is automatically unregistered when the plugin fiber unloads
  new WechatPublisherService(ctx, config)

  // Tool closure directly calls the service
  ctx.tools.register(defineTool({
    name: 'publish_article',
    // ... parameters / output same as before
    async execute(args) {
      const result = await ctx.wechatPublisher.publish(args.markdownPath, args.title)
      return { success: result.success, message: `发布完成,ID: ${result.articleId}` }
    },
  }))
}

Benefit: Other plugins can also call the publish capability by declaring inject: ['wechatPublisher']:

export const inject = ['wechatPublisher']
export function apply(ctx: Context) {
  // e.g., a "scheduled publish" plugin reuses the publish service
  ctx.wechatPublisher.publish('/path/to/article.md', 'Scheduled Article')
}
Service dependency diagram
Service dependency diagram
Service call flow
Service call flow

3. Event: Broadcasting Publish Results

Services solve "active invocation"; how to "notify all interested plugins when publish succeeds"? Use Event . Cordis events have five dispatch modes:

emit — Notify all listeners, do not await async results. Use case: publish success notification.

bail — Execute in order, stop on first return value. Use case: permission checks, decisions.

serial — Execute in order and await each. Use case: ordered processing.

parallel — Concurrent execution, await all. Use case: parallel tasks.

waterfall — Wrap downstream calls, can rewrite result or short-circuit. Use case: content rewriting, interception.

After publish succeeds, use emit to broadcast:

// Inside service, after publish completes
ctx.emit('wechat-publisher/published', {
  articleId: result.articleId,
  title,
  publishedAt: new Date().toISOString(),
})
Note: emit suits "notification", not "wait for all post-processing to finish". If the publish flow must wait for multiple plugins to complete processing, consider parallel .
Event broadcast diagram
Event broadcast diagram

Other plugins listen to this event — e.g., a "post-publish auto-archive to knowledge base" plugin:

export const name = 'publish-archiver'

export function apply(ctx: Context) {
  ctx.on('wechat-publisher/published', (event) => {
    console.log(`[archiver] Archiving article: ${event.title} (${event.articleId})`)
    // Write to database, send notification, trigger webhook...
  })
}
Event listener example
Event listener example

Notice the publisher and archiver plugins have zero code dependency — the publisher only emit s, not caring who listens or how many. That's the loose coupling events provide.

Using waterfall for Pre-Publish Interception

Even more powerful is waterfall — it lets other plugins rewrite or intercept content before publish. Example: a "sensitive word check" plugin:

ctx.on('wechat-publisher/before-publish', async (article, next) => {
  if (article.title?.includes('违禁词')) {
    throw new Error('Content contains sensitive words, publish blocked')
  }
  return next(article)
})

The following example illustrates waterfall control flow; the actual event signature must match the defined Event type.

Waterfall interception flow
Waterfall interception flow

Only calling next() continues the pipeline downstream; the current plugin can inspect or rewrite content before calling next(), achieving pre-publish interception.

4. Bundling & Distribution: From Patch to Installable Bundle

Bundle concept
Bundle concept

Previous episodes used --patch to load local plugins — that's development mode. To share with others, package as a bundle (组合包).

Bundle vs Profile

bundle (组合包) — An npm package with a config layer, declaring dsh.bundle. Answers: "What does this package contribute?"

profile — A launchable composition directory under $DSH_HOME/profiles/<name>. Answers: "Which bundles, in what order, compose this config?"

A bundle is what you write to distribute; a profile is what users launch with dsh --profile <name>.

Packaging Steps

Structure wechat-publisher as a package:

wechat-publisher/
├── package.json         # declares dsh.bundle
├── cordis.patch.yml     # config layer this package contributes
├── src/
│   ├── index.ts         # plugin entry
│   ├── service.ts       # WechatPublisherService (§2)
│   ├── tool.ts          # publish_article tool
│   ├── types.ts         # shared types
│   └── wechat/          # CDP publish implementation (cdp / markdown / publisher / index)
└── tsconfig.json
package.json

:

{
  "name": "dsh-wechat-publisher",
  "version": "0.1.0",
  "type": "module",
  "main": "src/index.ts",
  "files": ["src", "cordis.patch.yml"],
  "dependencies": {
    "baoyu-md": "^0.1.1",
    "ws": "^8.18.0"
  },
  "peerDependencies": {
    "@deepseek-ai/cordis": "^4.0.1",
    "@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
    "@deepseek-ai/schemastery": "^3.18.1"
  },
  "dsh": {
    "bundle": {
      "patch": "./cordis.patch.yml"
    }
  }
}

Three dependencies deserve attention: ws and baoyu-md are regular dependencies installed with the package; the three @deepseek-ai/* packages are declared as peerDependencies — they are provided by the Harness runtime, but dsh plugin add only links your package into the profile and does not install peers . During checkout development, the easiest approach is to symlink them to the source packages (guaranteeing same origin as runtime):

The symlink method below is for local development inside a Harness checkout; if distributing as an independent npm package, declare and install dependencies according to the target Harness version instead of relying on the dev repo's vendor paths.
cd wechat-publisher/node_modules
mkdir -p @deepseek-ai
ln -sfn ../../../vendor/cordis         @deepseek-ai/cordis
ln -sfn ../../../vendor/schemastery    @deepseek-ai/schemastery
ln -sfn ../../../packages/core/tools    @deepseek-ai/dsh-tools

Another rookie trap: internal relative imports must include the .ts extension ( from './service.ts') — DSH's loader only does TS transpilation; module resolution is delegated to Node ESM, and omitting the extension causes ERR_MODULE_NOT_FOUND at load time. cordis.patch.yml (note plugin rows reference by package name , not source path):

- insert:
    - id: wechat-publisher
      name: dsh-wechat-publisher
      config:
        appId: ''
        appSecret: ''
        mode: 'browser'
        defaultAuthor: 'CodeToSuccess'
        defaultDraft: true
        timeoutMs: 120000

Install into Profile

In the checkout containing the package directory, run:

dsh plugin --profile demo add ./wechat-publisher
Plugin add output
Plugin add output

First run initializes the profile (with @deepseek-ai/dsh-base as the first bundle), then links your package and appends it to dsh.profile.bundles. Verify the layered config, then start:

dsh --profile demo --dump-config   # shows "# == dsh-wechat-publisher" layer
Dump config output
Dump config output
dsh --profile demo                 # start
Startup logs
Startup logs
Running plugin
Running plugin

Uninstall is equally simple: dsh plugin --profile demo remove dsh-wechat-publisher.

Plugin remove output
Plugin remove output

Load Order (Important)

Effective config is composed layer by layer on top of an empty root; later layers override earlier ones:

Profile's bundles list (in order: first dsh-base, then each bundle)

Profile's own cordis.patch.yml Home-level $DSH_HOME/cordis.patch.yml (machine-local preferences)

Command-line --patch overlay

Note: Patch replaces the entire config row by id, not deep-merge fields. So if your bundle wants to override a previous layer's row, you must rewrite every key that row needs, not just the changed one.

Summary

Fiber state machine: PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED; dependency-driven loading, auto-unload on dependency loss, reload on recovery.

Service: encapsulate plugin capability as a reusable service, expose via ctx to other plugins; Cordis-managed Services are cleaned up with their owning Fiber's lifecycle.

Event five modes: emit (broadcast) / bail (short-circuit) / serial (sequential) / parallel (concurrent) / waterfall (pipeline interception).

Services handle active invocation; events handle decoupled notification; waterfall enables interception and rewriting.

Bundle is the distributable package; profile is the launchable composition; dsh plugin add installs, --dump-config verifies.

Layers compose in order; patch replaces whole row by id.

Complete flow diagram
Complete flow diagram

wechat-publisher complete : Episode 5 skeleton → Episode 6 tool → Episode 7 config → Episode 8 service/event/bundling. Four episodes give you the full loop of Harness plugin development: plugin model, tool development, config validation, servitization, event decoupling, and packaging distribution.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

TypeScriptEventServiceBundleplugin developmentCordisDeepSeek Harnessplugin distribution
AI Code to Success
Written by

AI Code to Success

Focused on hardcore practical AI technologies (OpenClaw, ClaudeCode, LLMs, etc.) and HarmonyOS development. No hype—just real-world tips, pitfall chronicles, and productivity tools. Follow to transform workflows with code.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.