DeepSeek Harness Plugin Development Skill
You are an expert plugin developer for the DeepSeek Harness (dsh) ecosystem. When asked to create, modify, or debug a dsh plugin, you MUST follow the specifications below precisely. Never invent APIs, types, or patterns not defined here.
Core Principles
- Everything in dsh is a plugin: adapters, tools, loggers, and the agent loop itself.
- Plugins interact exclusively through
Context(ctx). Never import or call other plugins directly. - All registrations MUST be reversible. Use
ctx.effect(),ctx.on(), orctx.plugin()so cleanup happens automatically on HMR/unload. - Load order is determined by
injectdependencies, not file order. - Function plugins MUST use named exports. Never use
export default.
Plugin Structure Contract
Every plugin module must export exactly these identifiers:
| Export | Required | Type | Purpose |
|--------|----------|------|---------|
| name | Yes | string | Unique plugin identifier, kebab-case |
| apply | Yes | (ctx: Context, config?: Config) => void \| Promise<void> | Plugin entry point |
| inject | No | readonly string[] | Required service dependencies |
| Config | No | z.ZodType<Config> | Declarative config schema using @deepseek-ai/schemastery |
Correct Plugin Skeleton
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
export const name = 'my-plugin'
export const inject = ['tools'] as const
export interface Config {
apiKey: string
timeout?: number
}
export const Config: z<Config> = z.object({
apiKey: z.string().required(),
timeout: z.number().default(30000),
})
export function apply(ctx: Context, config: Config) {
// Plugin logic here
}
Tool Development Contract
Import defineTool from @deepseek-ai/dsh-tools. The execute function receives pre-validated args and an execution context.
Key Rules
- Args are already validated. Do not re-validate inside
execute. - Return only what
output.schemadefines. Returning a raw string or mismatched object will fail validation. - Object output schemas MUST set
additionalProperties: false. Without it, the inferred type widens toRecord<string, JsonValue>and the execute return value fails type checking. Example:z.object({ results: z.array(z.string()) }, { additionalProperties: false })— for the schema-level form, pass it as a property on the object schema. - Respect cancellation. Check
exec.signal?.abortedat the start and between async steps. - Errors = isError. Throw
Errorfor failures; do not return error objects. - Never console.log. Use
ctx.logger.info/warn/error.
Tool Template
import { defineTool } from '@deepseek-ai/dsh-tools'
ctx.tools.register(defineTool({
name: 'search-docs',
description: 'Search internal documentation by keyword',
args: z.object({ query: z.string().required() }),
output: {
schema: z.object({ results: z.array(z.string()) }).additionalProperties(false),
render: (v) => `Found ${v.results.length} results`,
},
async execute(args, exec) {
if (exec.signal?.aborted) throw new Error('Cancelled')
const results = await searchInternal(args.query)
return { results }
},
}))
Event Hook Patterns
Hooks have three distinct signatures. Using the wrong one breaks the pipeline.
Waterfall Hooks (MUST call next())
Used for interception, permission checks, and request modification. You must either return a short-circuit value OR call return next().
// Permission guard example
ctx.on('tools/pre-execute', async (exec, next) => {
if (!hasPermission(exec.tool.name)) {
return { kind: 'deny', reason: 'Insufficient permissions' }
}
return next() // ← CRITICAL: forgetting this hangs the pipeline
})
Common waterfall events: tools/pre-execute, agent/request, tools/execute.
Serial Hooks (No next())
Used for side effects that don't modify flow.
ctx.on('agent/turn-stopping', async ({ reason }) => {
ctx.logger.info(`Turn stopping: ${reason}`)
})
Emit Hooks (Read-only observation)
Used for logging, metrics, and UI updates. Synchronous or async, no next().
ctx.on('tools/result', ({ tool, result, duration }) => {
metrics.record(tool, duration)
})
Anti-Patterns (Auto-Correct These)
When generating or reviewing code, actively detect and fix these issues:
| ❌ Wrong | ✅ Correct | Why |
|----------|-----------|-----|
| export default function apply | export function apply | Default export loses inject metadata |
| Bare setInterval(...) | Wrap in ctx.effect(() => { const t = setInterval(...); return () => clearInterval(t) }) | Leaks on HMR/unload |
| Return string from execute | Return object matching output.schema | Schema validation will reject primitives |
| Waterfall hook without next() | Always return next() or explicit short-circuit | Pipeline hangs indefinitely |
| Optional service in inject | Use ctx.get('name') at runtime | Missing optional dep blocks plugin load |
| Deep merge assumption in patch | Patch replaces entire config by id | Partial patches silently drop fields |
Configuration & Patching
- Config schemas use
@deepseek-ai/schemastery(Zod-compatible). - Patches replace the entire
configobject for a given pluginid. They do NOT deep merge. !!jsexpressions are ONLY allowed inplugin.configvalues anddisabledfields.- Environment variables:
!!js "process.env.MY_VAR"
Debugging Workflow (Independent Project)
When developing outside the main harness repo:
- Declare the bundle layer in
package.json—"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }. A bare string like"bundle": "headless"is NOT recognized by the CLI; onlydsh.bundle.patch !== undefinedqualifies (apps/cli/src/plugin.ts,reconcilePlugins). Provide a rootcordis.patch.ymlthat inserts the plugin row, e.g.:- insert: - id: my-plugin name: my-plugin - Install into the web profile:
npx @deepseek-ai/dsh plugin --profile web add .(installs as alink:dependency of~/.dsh/profiles/web). Restart the host afterwards — the plugin set is scanned at boot and cached. - Verify config tree:
npx @deepseek-ai/dsh --profile web --dump-config - Test with task:
npx @deepseek-ai/dsh --profile web "test my plugin" - Apply temporary override:
npx @deepseek-ai/dsh --profile web --patch ./overlay.yml "task" - Type check:
tsc --noEmit
Always confirm the plugin appears in --dump-config output before debugging runtime behavior.
Web Client Bundle Contract (Browser Half)
A plugin can also ship browser-side JS without forking the harness repo. All paths below are verified against packages/client/modules/src/index.ts.
- Declaration (in
package.json):"client": { "platform": "web" }inside the existingdshfield;exports["./client"]: string or{ default: "./lib/client.js" };exports["./package.json"]: "./package.json"is MANDATORY. The host resolves<pkg>/package.jsonwithrequire.resolveto read metadata; if Node throwsERR_PACKAGE_PATH_NOT_EXPORTED, the package is permanently marked as a non-client row — Node tools keep working while the Web buttons 404 silently.
- Bundle format ("lazy CJS closure factory", identical to
packages/client/tsdown.client.tsbanner/intro/footer). Plain tsc output does NOT qualify — it appendsexport {}which breaks classic-script parsing:window.__ModuleLoader__.load({ id: "<pkg>", factory: (require) => { var module = { exports: {} }; var exports = module.exports; /* bundle body: no ESM syntax; assign exports via module.exports */ return module.exports; } }); - Registration id equals the package name; the factory return value
(
{ name, inject: [], apply }) becomes the plugin module table used to build the browser fiber. Setinject: []unless the client actually needs host services. - Serving: host composes rows into
window.__DSH_BOOT__and serves each bundle at/plugins/<id>/client.js?rev=<content-hash>. Diagnose via DevTools: missing entry inwindow.__DSH_BOOT__.entries→ host-side scan failed (usually the./package.jsonissue); entry present but console errors → bundle format/registration bug.
The reference implementation lives in this repo: src/client.ts + scripts/wrap-client.mjs.
HTTP 扩展点(鉴权 / 会话隔离 / 拦截 /api / 注入前端)
以下结论均核对过 deepseek-harness/packages/ 源码(harness 版本见各文件路径)。当需求涉及 识别调用者身份 或 拦截既有 /api 请求 时,上面的 connection.rpc.handle 不够用 —— 用这一节。
一、能挂的三个点
1. ctx.webServer —— 唯一能拿到 HTTP 头的入口
// packages/host/webserver/src/index.ts
register({ kind: 'exact' | 'prefix', path, handler: (req, res) => void | Promise<void> }) => disposer
registerUpgrade({ path, handler }) => disposer // 同路径重复注册直接抛错
registerFallback(handler) => disposer // 唯一席位,已被 SPA dist 服务占用
tapIndex((html: string) => string) => disposer // index.html 原文变换
匹配顺序是 exact 表优先,其次最长前缀(WebServer.match)。connection 插件把 /api 注册成 prefix,所以:
ctx.webServer.register({ kind: 'exact', path: '/api/session.list', handler })
能合法压过 /api,拿到原生 IncomingMessage(可读 Cookie、Header、Body)。
tapIndex 在所有结构化注入行之后应用,所以插在 <body> 之后的脚本排在所有注入行之后、应用自带 <script> 之前。
2. ctx.apiProxy —— 同进程取真实数据,不自环
域 → 方法(见 packages/host/apiproxy/src/api/index.ts):sessions / subagents / host / workspace / skills / agentPresets / goals / settings / credentials / llm / downloads / respond。
const api = ctx.get('apiProxy')
await api.sessions.list({ rpcId, payload: {} }) // → { rpcId, result }
result 形如 { ok: true, value } | { ok: false, error }。
⚠ 方法前缀 → 域名的映射不规则,必须查表,不能靠加 s 推导:
| 方法前缀 | 域 |
| --- | --- |
| session | sessions |
| subagent | subagents |
| agentPreset | agentPresets |
| goal | goals |
| host / workspace / settings / credentials / llm | 同名 |
切分用 method.lastIndexOf('.')(agentPreset.openDocument 只有最后一个点是分隔)。写错的表现是运行时 api method xxx unavailable,不是编译错误。
官方实现不自行校验 payload(校验在 toFetchHandler 的 UNARY_ROUTES 表里),直接调方法时要自己保证 payload 形状。
3. ctx.connection.rpc.handle —— 拿不到请求头,别用来做鉴权
// packages/client/connection/src/rpc.ts
type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<RpcResult>
没有 req,读不到 Cookie,识别不了调用者。只适合无鉴权的功能端点。另有 rpc.intercept('/api', matcher, handler, options) 可接管 /api 上的端点,但同样是这个签名 —— 一样拿不到头。
二、地基限制(架构决策前必读)
isTrustedApiRequest不是鉴权层。 源码注释原文:"this fence is not an auth layer"。它只防 DNS rebinding(验 Host)和跨站(验 Origin / Sec-Fetch-Site)。任何"登录"都必须自己实现。- WebSocket 事件流无法在进程内隔离。
/api/events.mux与/api/events.host是 upgrade 路由,registerUpgrade重复注册抛错 → 插件接管不了;且官方以 空 payload 打开 mux 流(api.events.mux({ rpcId, payload: {} })),不带任何客户端身份 → 每个浏览器都收到全量会话事件。要做真正的事件隔离,只有另起网关进程做反向代理 + 逐帧过滤。这是"纯插件方案"的能力天花板,评估需求时必须先讲清。 registerFallback只有一个席位,已被 SPA dist 服务占用,插件抢不到。- 无法把请求转交给同路径的官方 handler。 插件 exact 注册
/api/session.export后,内部再请求该路径只会命中自己(自环)。所以session.export这类GET+ query 参数、不走 JSON 信封的端点只能自己重新实现或放弃。 DSH_HOME环境变量可整体重定向数据根(packages/util/home-paths:configured >$DSH_HOME>~/.dsh)。这是"每用户独立数据目录"、进而"每用户独立进程"方案的支点。
三、错误码
RpcError 是闭合判别联合(packages/host/apiproxy/src/api/rpc.schema.ts 的 rpcErrorSchema),没有 unauthorized / forbidden。业务层拒绝用 code: 'bad-request', details: { issues: [] };不要用 internal(部分客户端会触发重试)。HTTP 状态保持 200 —— 状态只表达载体层,业务错误一律走 200 + 错误分支。
四、踩坑
- 切域名用
lastIndexOf('.')并查映射表(见上)。 cordis的ctx.get(name)返回代理对象:自有字段可枚举,原型上的属性不在Object.keys里。诊断时别只看 keys。- 切换数据源要关掉旧连接:SQLite 会一直锁文件,Windows 上
EBUSY,删文件重建都会失败。 - 测试里假
res必须是 EventEmitter(补on/off):invoke会在res上挂close监听传取消信号,缺了就 500...is not a function。 - 测试里假
req别用Object.assign(Readable.from(...), {...}):会覆盖Readable的on/off,令for await (const chunk of req)永久挂起。用class X extends Readable。 - 浏览器 bundle 不能含 TS 特有语法(
enum/namespace/ 参数属性 /satisfies/declare global),tsc 会产出无法直接执行的语句。用interface+as unknown as X收窄 DOM 元素类型。 systemPrompt分节文本会被强制{{variable}}插值,任何{{...}}都会令装配抛错malformed prompt variable reference。注入用户自由文本前要转义。
五、验证手段(无浏览器也能测)
用最小假 cordis 上下文驱动真实插件,比 --dump-config 更能验证行为:
const ctx = { logger, get: n => ctx.services[n], services: { webServer, apiProxy }, effect: fn => effects.push(fn) }
- 假
webServer:register存进Map并暴露request({ method, path, headers, body });tapIndex收集变换。 - 假
apiProxy:只实现被影子化的方法,返回{ rpcId, result }。 - 清理时若
rmSync报EBUSY,说明有连接没释放 —— 一个免费的泄漏检测器。 - 清理失败会掩盖测试本身的异常,把清理放在 finally 里单独 try/catch 报告。
参考路径(harness 源码,只读)
packages/host/webserver/src/{index.ts,injections.ts}—— 路由与注入packages/host/apiproxy/src/{index.ts,api-proxy.ts,fetch/handler.ts,api/rpc-map.ts}packages/client/connection/src/{index.ts,rpc-host.ts,rpc.ts,websocket-downlink.ts}packages/client/connection/src/api-request-trust.ts—— 信任栅栏(明确非鉴权)packages/util/home-paths/src/index.ts——DSH_HOME解析
微信扫一扫