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.
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:
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')
}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 .
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...
})
}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.
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
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-toolsAnother 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: 120000Install into Profile
In the checkout containing the package directory, run:
dsh plugin --profile demo add ./wechat-publisherFirst 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" layerdsh --profile demo # startUninstall is equally simple: dsh plugin --profile demo remove dsh-wechat-publisher.
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.
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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
