SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-21

Tự viết plugin dsh cho DeepSeek Harness

Tạo plugin dsh từ thư mục trống: các field quan trọng trong package.json, file patch để mount plugin, một tool thực tế và 2 hook bạn cần.

Plugin dsh thực sự là gì

Plugin dsh là một npm package export một hàm apply và đi kèm một file YAML nhỏ để yêu cầu DeepSeek Harness load plugin đó. Bạn không cần học một plugin SDK riêng trước. dsh là một ứng dụng Cordis, và “mọi thứ đều là plugin” được hiểu theo đúng nghĩa đen: tool registry, agent loop, session store và web server đều là các node trong cùng một plugin tree mà package của bạn tham gia.

Cordis là một framework composition tổng quát. Framework này được xây dựng độc lập và đã được dùng nhiều năm làm nền tảng cho chatbot Koishi. Nó xử lý việc load và unload, đồng thời resolve dependency giữa các plugin. Cordis không biết gì về agent. Mọi thành phần liên quan đến agent đều đến từ các package của harness được xếp trên nó. Vì vậy, cấu trúc plugin bên dưới mới ngắn như vậy. Phần lớn chức năng bạn nhận được là do framework kế thừa.

Một plugin có 2 phần. Phần host chạy trong Node, đăng ký tool và event listener, đồng thời có thể cung cấp service riêng. Phần browser chạy bên trong Web UI và đăng ký các interface slot. Plugin đầu tiên gần như luôn chỉ có phần host. Hãy xem phần browser là tùy chọn cho đến khi bạn thực sự cần.

Tài liệu này được viết dựa trên @deepseek-ai/dsh version 0.1.0-rc.7, npm latest tag vào ngày 19 August 2026. dsh đang ở giai đoạn developer preview và README của chính dự án cũng nói rằng sẽ có các thay đổi phá vỡ compatibility. Mọi tên key bên dưới đều được kiểm tra từ tài liệu upstream và repository tại thời điểm đó. Hãy kiểm tra lại trước khi phụ thuộc vào chúng, vì preview API có thể đổi tên field giữa các release candidate. Nếu harness chưa chạy, trước tiên hãy cài đặt theo DeepSeek Harness trên VPScấu hình API key và model của dsh, rồi quay lại đây.

Nạp một file tạm trước khi đóng gói bất kỳ thứ gì

Đóng gói ngay từ đầu là cách chậm để tìm hiểu cơ chế này. Hãy nạp một file duy nhất, xác nhận runtime gọi được code của bạn, rồi mới đóng gói.

Tạo một thư mục bên ngoài checkout của harness và đặt một file vào đó.

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

export const name = 'hello-plugin'

export function apply(ctx: Context) {
  console.log('[hello-plugin] plugin loaded')
}

export const name là metadata dùng để gắn nhãn plugin trong phần chẩn đoán. apply là toàn bộ contract: Cordis gọi nó một lần và truyền vào một context có phạm vi trong plugin của bạn. Mọi thứ bạn đăng ký trên context đó sẽ được tự động hủy khi plugin bị dispose.

Trong cùng thư mục, tạo cordis.yml.

- insert:
    - id: hello
      name: '/absolute/path/to/scratch-plugin/hello.ts'

Bây giờ khởi động một profile với file đó được layer lên trên.

dsh web --patch ./scratch-plugin/cordis.yml

Nếu dsh không có trong PATH, npx @deepseek-ai/dsh web --patch ./scratch-plugin/cordis.yml thực hiện cùng công việc. Cách chạy qua npx có thể cung cấp một release candidate cũ đã được cache thay vì phiên bản được mô tả trong hướng dẫn này. Vì vậy, nếu harness từ chối thẳng một flag được tài liệu hóa, hãy xử lý các lỗi cài đặt và phiên bản dsh trước khi nghi ngờ file của chính bạn. Bạn phải thấy [hello-plugin] plugin loaded trong terminal đã khởi động dsh. Nếu không thấy gì, row chưa được resolve.

Trường name nhận tên package npm hoặc path trên filesystem. Tài liệu upstream nêu rõ path phải là absolute. ./hello.ts dạng relative là điều đầu tiên cần kiểm tra khi scratch plugin không tạo output. Điều thứ hai là phần mở rộng của file. Loop được tài liệu hóa chạy bằng pnpm dsh web --patch ... từ một clone của repository harness, trong đó các entry TypeScript được load qua tsx. Nếu dsh của bạn được cài từ npm, hãy trỏ row vào JavaScript thuần hoặc build file trước.

--patch là flag của launcher. Overlay của nó được áp dụng sau cùng, sau mọi bundle và sau cả profile patch của bạn. Vì vậy, scratch overlay luôn được ưu tiên. Đây chính xác là điều bạn cần khi đang lặp lại quá trình thử nghiệm.

Viết công cụ nhỏ nhất nhưng có ích

Một dòng log chứng minh plugin được nạp. Một tool chứng minh plugin đã trở thành một phần của agent.

import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'greet',
    description: 'Greet someone by name.',
    parameters: {
      name: { type: 'string', required: true, description: 'The name to greet' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args) {
      return `Hello, ${args.name}!`
    },
  }))
}

export const inject = ['tools'] là dòng mọi người thường bỏ qua. Các entry trong cấu hình Cordis được khởi động đồng thời, nên vị trí của một dòng trong file không đảm bảo thứ tự nạp. Thứ tự được xác định bằng các dependency đã khai báo. inject yêu cầu Cordis chờ đến khi ctx.tools tồn tại rồi mới gọi apply của bạn. Nếu thiếu dòng này, code có thể chạy vào lúc registry chưa tồn tại để đăng ký.

Phần còn lại của object là contract mà model nhìn thấy. parameters là schema của các argument, còn execute nhận các argument đã được parse theo schema đó. output.schema mô tả giá trị mà execute trả về, còn render chuyển giá trị đó thành các content block mà model đọc. Tách riêng hai phần này cho phép interface hiển thị một nội dung, trong khi model đọc một nội dung khác.

Khởi động profile rồi yêu cầu assistant chào một người bằng tên. Phản hồi sẽ đi qua execute của bạn. Việc đăng ký thông qua ctx có thể hoàn tác, nên khi dispose plugin, tool sẽ được unregister tự động. Với mọi tài nguyên Cordis không thể biết, chẳng hạn socket hoặc file handle, hãy gọi ctx.effect() và truyền vào một disposer.

Hai điểm mở rộng mà plugin đầu tiên thực sự dùng đến

Danh sách đầy đủ các điểm nối khá dài. Hai điểm này bao phủ gần như mọi plugin đầu tiên.

Các conversation event là stream bền vững và được ghi log. Tên của chúng là session/event, turn/start, turn/end, step/start, step/end, user/message, assistant/message, assistant/chunk, tool/calltool/result. Bạn gắn một listener thông thường.

ctx.on('tool/call', (payload) => {
  console.log('[my-plugin] tool/call', JSON.stringify(payload))
})

In payload một lần rồi đọc nó. Đừng chép tên các field của payload từ bất kỳ guide nào, kể cả guide này, vì cấu trúc payload là phần thay đổi nhiều nhất trong preview API.

Điểm mở rộng thứ hai là waterfall. agent/pre-step, agent/request, agent/request-error, llm/stream và các event tools/* là waterfall, còn waterfall listener có signature khác. Nó nhận một callback next, và chain chỉ tiếp tục nếu listener gọi callback đó.

ctx.on('agent/request', async (payload, next) => {
  const startedAt = Date.now()
  const downstream = await next()
  console.log('[my-plugin] model request took', Date.now() - startedAt, 'ms')
  return downstream
})

Nếu quên await next(), bạn chưa thêm hook. Bạn đã thay model call bằng không có gì, nên agent dừng ở đó. Đây là hành vi short circuit được thiết kế cho gateway plugin cố ý từ chối một request. Khác biệt này gây ra phần lớn nhầm lẫn khi viết plugin đầu tiên. Hãy viết lệnh gọi next() trước khi viết bất kỳ phần nào xung quanh nó.

agent/request bọc chính model call. Payload của nó chứa agent thực hiện call, số turn đang mở, step mà request thuộc về và abort signal của turn đó. Vì vậy đây là điểm nối phù hợp cho request logger hoặc rate limiter. Các waterfall tools/* có cùng cấu trúc nhưng nằm thấp hơn một lớp. tools/pre-execute cho phép, từ chối hoặc yêu cầu approval trước khi dispatch. tools/execute bọc dispatch. tools/post-execute có thể thay thế hoặc chặn kết quả đã chuẩn hóa. tools/result chỉ quan sát outcome đã được cố định.

Đóng gói để người khác có thể cài đặt

Một bundle là một npm package có package.json khai báo trường dsh.bundle trỏ đến file patch của nó. Khai báo này là điểm khác biệt duy nhất giữa một file tạm và một package có thể cài đặt.

{
  "name": "dsh-plugin-hello",
  "version": "0.1.0",
  "type": "module",
  "main": "lib/index.js",
  "files": ["lib", "cordis.patch.yml", "README.md", "LICENSE"],
  "engines": { "node": "^22.19 || >=24", "dsh": ">=0.1.0-rc.6" },
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
  "keywords": ["dsh-plugin", "deepseek-harness"],
  "scripts": { "build": "tsdown", "prepare": "pnpm run build" },
  "exports": {
    ".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" },
    "./cordis.patch.yml": "./cordis.patch.yml",
    "./package.json": "./package.json"
  }
}

cordis.patch.yml nằm ngay bên cạnh file đó và có nội dung ngắn.

- insert:
    - id: dsh-plugin-hello
      name: dsh-plugin-hello

Dòng name là tên package, vì vậy hai chuỗi đó phải khớp nhau. Dòng id là giá trị mà một layer sau dùng để nhắm đến khi người dùng override cấu hình của bạn, nên hãy chọn một giá trị ổn định và không dùng lại nó cho plugin khác.

files phải liệt kê cordis.patch.yml. Nếu bỏ qua, tarball đã publish sẽ chứa một dsh.bundle.patch trỏ đến file chưa được đóng gói. Khi đó package vẫn cài được nhưng không thêm gì vào tree.

Cài package vào một profile từ directory chứa thư mục plugin của bạn.

dsh plugin --profile demo add ./dsh-plugin-hello
dsh --profile demo --dump-config
dsh --profile demo

dsh plugin --profile <name> chuyển các argument còn lại cho pnpm bên trong directory của profile đó, nên addremove hoạt động như pnpm. Gỡ cài đặt bằng dsh plugin --profile demo remove dsh-plugin-hello. Các profile webheadless tự tạo từ các template được ship kèm trong lần đầu sử dụng. Mọi tên profile khác phải được tạo bằng dsh plugin.

Vì sao row của bạn không xuất hiện trong cây đã compose

Quá trình composition bắt đầu với danh sách entry rỗng và xếp các layer theo thứ tự cố định. Trước tiên là từng bundle được khai báo trong dsh.profile.bundles của profile, theo đúng thứ tự trong danh sách. Tiếp theo là cordis.patch.yml của profile. Sau đó là $DSH_HOME/cordis.patch.yml. Cuối cùng là mọi overlay --patch được truyền từ command line. Layer đến sau sẽ thay thế row đến trước theo id.

Profile nằm trong $DSH_HOME/profiles/<name>. Một thư mục profile chứa package.json, trong đó có manifest dsh.profile cùng danh sách bundles theo thứ tự, và file patch riêng của người dùng. Tên bundle được resolve trước từ bản cài đặt dsh, sau đó từ node_modules của profile. Đây là nơi pnpm đặt plugin nằm ngoài cây mặc định.

dsh --profile demo --dump-config in ra cây đã compose hoàn chỉnh mà không boot bất kỳ thứ gì. Đây là ranh giới để debug. Nếu không có row id của bạn, vấn đề nằm ở composition: một tên không resolve được hoặc file patch chưa từng được đóng gói. Nếu row đã có nhưng không có gì xảy ra, vấn đề nằm trong code của bạn. Hãy trả lời câu hỏi đó trước để tránh phần lớn việc phỏng đoán.

Lỗi load thực sự xuất hiện ở đâu

Lỗi phát sinh bên trong apply sẽ hiển thị rõ. Process thoát với exception đó và bạn nhận được stack trace trỏ đến dòng code của mình.

Lỗi resolve thường không hiển thị rõ. Loader ghi module không thể resolve thông qua Cordis logger thay vì làm process crash. Tutorial gốc cảnh báo rằng các message này có thể bị bỏ sót khi startup vì chúng được ghi trước khi các console exporter được gắn vào. Vì vậy, lỗi typo trong path trông giống hệt một plugin đã load nhưng không làm gì cả. Đó là lý do nên chạy kiểm tra --dump-config ở trên trước khi đọc code.

Trong lúc phát triển, hãy đặt console.log làm statement đầu tiên trong apply. Nếu không thấy output này, bạn sẽ biết vấn đề nằm ở phần nào. Sau đó xóa nó cũng không tốn công. Trên server, hãy chạy harness ở foreground trong khi lặp lại các thay đổi, thay vì chạy dưới service manager. Như vậy, output của loader sẽ xuất hiện trong terminal thay vì nằm trong journal mà bạn phải mở ra đọc.

Lặp lại mà không phải khởi động lại toàn bộ

Câu trả lời thực tế cho phần host hiện nay là khởi động lại. Gói ứng dụng web được phát hành với cơ chế hot module reload dùng chung bị tắt. File này có ghi chú rằng cơ chế đó sẽ được bật lại sau khi kiểm thử xong vòng đời reload. Chuỗi reload phía client luôn được mount nhưng ở trạng thái chờ cho đến khi một rebuild watcher ghi lại các client bundle. Vì vậy, nó cũng không làm gì cho phần Node của bạn.

Hãy làm cho việc khởi động lại nhanh và đơn giản thay vì cố tìm một cơ chế reload hiện chưa có. Giữ plugin trong một file. Nạp plugin bằng --patch thay vì cài vào profile, để không có bước build hoặc bước pnpm nào nằm giữa lúc sửa và lúc chạy. Đăng ký mọi thứ thông qua ctx để việc khởi động lại không để lại tool trùng hoặc listener cũ. Bọc mọi tài nguyên bạn tự cấp phát bằng ctx.effect() và một disposer thực sự, vì triệu chứng thường gặp khi thiếu disposer là lần chạy thứ hai thất bại do một port vẫn đang bị lần chạy đầu tiên giữ.

Nếu bạn phát triển trên một harness chạy trên server thay vì laptop, không có gì ở trên thay đổi. Tuy nhiên, cấu hình bind của Web UI vẫn quan trọng. Bind loopback trên port 3080 giải thích vì sao trang không tự mở và cách xử lý.

Phần browser và mức độ có thể tin cậy

Chỉ thêm phần này khi plugin cần interface riêng. Phần này được khai báo trong cùng trường dsh với bundle.

{
  "dsh": {
    "client": {
      "platform": "web",
      "inject": [],
      "external": [],
      "immediately": false
    }
  },
  "exports": {
    ".": "./src/index.ts",
    "./client": "./src/client/apply.ts",
    "./package.json": "./package.json"
  }
}

"platform": "web" là bắt buộc. Scanner sẽ báo lỗi nếu package không có export ./client. Vì vậy, export map là một phần của manifest, không chỉ là tiện ích. Entry phía client nhận Cordis Context được mở rộng bằng client runtime type. Mọi registration đều phải thực hiện bên trong apply thông qua ctx.slots.register. Không được có side effect ở cấp module tại đó.

import type { Context } from 'cordis'
import type { DshClientContext } from '@deepseek-ai/dsh-client-runtime'

export async function apply(ctx: Context & DshClientContext) {
  ctx.slots.register({ name: 'domain.entry.slot' }, MyComponent)
}

Có 2 chi tiết cần biết trước khi bắt đầu. inject trong client manifest chỉ là tài liệu, không dùng để lập lịch. Nó ghi lại các dependency edge ở cấp package và không điều khiển thứ tự activation. external là nơi khai báo các module request nằm ngoài baseline, để chúng được materialize trước khi plugin yêu cầu. Đây là phần thay đổi nhanh nhất của preview. Vì vậy, hãy đọc packages/client/AGENTS.md trong repository của harness vào ngày bạn viết code, không phải vào ngày bạn đọc một hướng dẫn về phần này.

Xuất bản và nêu rõ plugin tác động đến đâu

Thêm topic dsh-plugin vào repository GitHub sẽ đưa repository đó vào danh sách mà mọi người xem khi tìm plugin. Đây là một cam kết về độ tin cậy với người lạ và đi kèm các trách nhiệm. Những trách nhiệm đó phản chiếu các điểm mà hướng dẫn kiểm tra plugin dsh trước khi cài đặt yêu cầu người đọc xem xét. Vì vậy, viết theo checklist đó là cách dễ nhất để đáp ứng yêu cầu.

  • Pin dependency của bạn. Một khoảng phiên bản dùng dấu mũ trên dependency gián tiếp có thể khiến package từng an toàn tuần trước chạy code khác trong tuần này. Đây chính là cơ chế đứng sau các cuộc tấn công chuỗi cung ứng npm vào server.
  • Manifest phải nêu rõ bạn tác động đến những gì. Danh sách inject là bản tóm tắt trung thực, machine-readable về các harness service mà bạn sử dụng. Reviewer có thể đọc nó trong vài giây và hình thành đánh giá từ đó.
  • Không gọi network âm thầm. Nếu tool gọi API, hãy ghi rõ host trong README và cho phép cấu hình endpoint. Plugin kết nối đến một server chưa từng được nhắc đến sẽ bị những người audit các plugin này gỡ khỏi danh sách.
  • Giữ files ở mức tối thiểu. Đưa toàn bộ working folder lên là cách khiến một file credential bị bỏ sót lọt vào registry.
  • Cung cấp cho trình cài đặt qua git một script prepare có thể build mà không phụ thuộc vào các giả định chỉ đúng trong môi trường dev, đồng thời ghi rõ trong README rằng họ phải allowlist quá trình build đó trong pnpm-workspace.yaml của profile.
  • Ghi ngày trong README, đối chiếu với release candidate mà bạn đã build và test. Người dùng preview API cần biết bạn đã dùng phiên bản nào.

Để xem một plugin hoàn chỉnh trông như thế nào từ bên ngoài, hãy đọc các plugin dsh đáng cài đặt và chú ý README của từng plugin cho biết những gì trước khi bạn cài. Nếu bạn từng viết extension cho một agent khác, cách các plugin Claude Code được cấu thành là một đối chiếu hữu ích. Harness cung cấp cho bạn một object graph đang hoạt động và cơ chế đăng ký có thể hoàn tác. Điều này mạnh hơn một manifest liệt kê các file, đồng thời cũng đi kèm trách nhiệm lớn hơn.

FAQ

Tôi có cần publish lên npm để viết plugin dsh không?

Không. Chỉ cần một đường dẫn filesystem trong overlay cordis.yml, được nạp bằng dsh web --patch ./scratch-plugin/cordis.yml, là đủ để chạy code của bạn bên trong harness. Đường dẫn này phải là đường dẫn tuyệt đối. Việc đóng gói chỉ quan trọng khi người khác cài plugin. Ngay cả khi đó, bạn vẫn có thể dùng dsh plugin --profile demo add ./my-plugin để cài một thư mục local và kiểm thử dạng đã đóng gói mà không cần truy cập registry.

Vì sao plugin của tôi được nạp nhưng tool không bao giờ xuất hiện?

Trước tiên hãy chạy dsh --profile demo --dump-config. Nếu không thấy row id của bạn trong output đó, plugin chưa được mount. Khi đó nguyên nhân nằm ở quá trình composition, không phải code. Nếu row có trong output, hãy kiểm tra export const inject = ['tools']. Các entry trong cấu hình Cordis khởi động đồng thời, nên thứ tự file không quyết định thứ tự load. Nếu thiếu khai báo đó, Cordis không chờ tool registry. Khi đó apply có thể chạy vào lúc ctx.tools chưa sẵn sàng để đăng ký.

cordis.yml và cordis.patch.yml khác nhau thế nào?

cordis.yml là danh sách entry đầy đủ. cordis.patch.yml là một layer được áp dụng lên danh sách đó, nhắm đến các row theo id để chèn row mới hoặc thay thế cấu hình hiện có. Một bundle trỏ đến patch file riêng của nó thông qua dsh.bundle.patch trong package.json. Các layer được áp dụng theo thứ tự cố định: mọi bundle theo thứ tự được liệt kê trong profile, tiếp đến patch file của profile, rồi $DSH_HOME/cordis.patch.yml, sau đó là mọi overlay --patch. Layer áp dụng sau sẽ được ưu tiên.

Có thể hot reload plugin dsh khi agent đang chạy không?

Không thể hot reload phần host trong web profile, tính đến 0.1.0-rc.7. Bundle đó tắt row shared hot module reload và có ghi chú trong file rằng row này sẽ được bật lại sau khi lifecycle reload được kiểm thử. Thay vào đó, hãy thiết kế để restart nhanh: dùng một file, nạp qua --patch mà không cần build step, và thực hiện mọi đăng ký qua ctx để không có tài nguyên bị rò rỉ từ lần chạy này sang lần chạy tiếp theo. Dùng ctx.effect() cùng một disposer cho các tài nguyên mà Cordis không thể tự dọn dẹp.