SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor

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.

Một dsh plugin thực sự là gì

dsh plugin là một npm package export hàm apply và kèm theo một file YAML nhỏ để DeepSeek Harness biết cần 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 nghĩa đen: tool registry, agent loop, session store và web server đều là các mục 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 framework Koishi. Cordis xử lý việc load và unload plugin, đồng thời giải quyết dependency giữa các plugin. Nó 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 Cordis. Vì vậy, cấu trúc plugin bên dưới rất nhỏ. Phần lớn chức năng bạn nhận được là chức năng kế thừa.

Một plugin có hai 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 hầu 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.

Hướng dẫn 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 nó cho biết sẽ có các thay đổi phá vỡ compatibility. Mọi key name bên dưới đều được đọc từ tài liệu upstream và repository vào ngày đó. Khi kiểm tra lại hướng dẫn vào ngày 12 October 2026, npm latest tag là 0.2.0-rc.2, và các phần bên dưới chỉ rõ những điểm release đó hoạt động khác. Hãy đọc lại key name 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 VPS và API key cùng cấu hình model của dsh, sau đó 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ường chạm tới

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

Các sự kiện hội thoại là luồng dữ liệu bền vững và được ghi log. Trong 0.1.0-rc.7, tên của chúng là session/event, turn/start, turn/end, step/start, step/end, user/message, assistant/message, assistant/chunk, tool/call và tool/result. Bản phát hành 0.2.0-rc.2 không có assistant/chunk. Chức năng streaming trực tiếp đã chuyển sang agent/assistant-stream. Các frame của nó chỉ tồn tại trong process và không bao giờ được ghi log. Một sự kiện bền vững mới là assistant/attempt, dùng để lưu các lần thử bị lỗi, thử lại và bị hủy. 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ó. Không sao chép tên field của payload từ bất kỳ tài liệu hướng dẫn nào, kể cả tài liệu này, vì shape của payload là phần thay đổi nhiều nhất trong một preview API.

Điểm mở rộng thứ hai là waterfall. agent/pre-step, agent/request, agent/request-error, llm/stream và các sự kiện tools/* là các waterfall. Listener của waterfall có signature khác. Nó nhận một callback next, và chain chỉ tiếp tục khi callback này được gọi.

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 tại đó. Đây là hành vi được thiết kế cho một gateway plugin cố ý từ chối 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ỳ nội dung nào xung quanh nó.

agent/request bao 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 mở rộng phù hợp cho request logger hoặc rate limiter. Các waterfall tools/* có cùng shape nhưng nằm thấp hơn một layer. tools/pre-execute cho phép, từ chối hoặc yêu cầu phê duyệt trước khi dispatch. tools/execute bao bọc dispatch. tools/post-execute có thể thay thế hoặc chặn kết quả đã được chuẩn hóa. tools/result chỉ quan sát kết quả đã được cố định.

Đóng gói thành bundle để 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 patch file của package. 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 thứ 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 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à mục tiêu mà layer về sau dùng 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 bao giờ dùng lại nó cho plugin khác.

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

Cài đặt package vào một profile từ thư mục 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 thư mục profile đó, nên add và remove hoạt động theo cách pnpm hoạt động. Gỡ cài đặt bằng dsh plugin --profile demo remove dsh-plugin-hello. Các profile web, headless, sdk, sdk-minimal và acp được cung cấp sẽ tự tạo từ template trong lần dùng đầu tiên. Trong 0.2.0-rc.2, mọi tên khác được tạo theo một trong hai cách. dsh plugin khởi động profile chỉ từ base bundle. dsh --profile <name> --from-default-profile <template> sao chép danh sách bundle của một template được cung cấp. Khởi động một custom profile chưa tồn tại sẽ thất bại và hiển thị gợi ý chạy 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 khởi động lại toàn bộ

Trong 0.1.0-rc.7, cách xử lý phần host rất đơn giản: khởi động lại. Web application bundle của bản phát hành đó vô hiệu hóa shared hot module reload row, đồng thời file có ghi chú rằng tính năng này sẽ được bật lại sau khi kiểm thử xong reload lifecycle. Trong 0.2.0-rc.2, override đó đã bị xóa. Base bundle mount @deepseek-ai/dsh-hmr trong mọi profile với root: [], kèm comment "Profile configuration reloads by default; module roots are opt-in." Các chỉnh sửa hợp lệ trong profile và home patch file hiện được áp dụng lại mà không cần khởi động lại. Source của plugin vẫn không được watch, trừ khi bạn thêm thư mục của plugin vào root của row đó. Thêm, xóa hoặc cập nhật bundle vẫn cần khởi động lại. Client side reload chain luôn được mount nhưng ở trạng thái idle cho đến khi một rebuild watcher ghi lại client bundles, nên chain này cũng không làm gì với phần Node của bạn.

Hãy làm cho việc khởi động lại trở nên nhanh gọn thay vì phụ thuộc vào reload mà bạn phải chủ động bật. Giữ plugin trong một file. Nạp plugin bằng --patch thay vì cài vào profile, để không có build step hay pnpm step nào nằm giữa lúc chỉnh sửa và lúc chạy. Đăng ký mọi thứ thông qua ctx để một lần khởi động lại không để lại tool trùng hoặc listener cũ. Đặt mọi tài nguyên do bạn tự cấp phát trong ctx.effect() và cung cấp disposer thực sự, vì biểu hiện 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 bị lần chạy đầu tiên chiếm giữ.

Nếu bạn phát triển trên harness chạy trên server thay vì laptop, các điểm trên không thay đổi, nhưng binding của Web UI lại quan trọng. Khi launch cục bộ, Web UI được phục vụ tại http://127.0.0.1:3080 và mở trong trình duyệt mặc định, hoặc bỏ qua việc mở trình duyệt nếu bạn truyền --no-open. Khi launch qua SSH, chương trình chỉ in host URL, nên không có gì mở trên máy của bạn. Bind loopback trên port 3080 giải thích vì sao trang không mở trong trường hợp đó và cách xử lý.

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

Chỉ thêm phần này khi plugin cần giao diện 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 kiểu runtime phía client. Mọi registration đều phải nằm 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ỉ mang tính 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 khai báo các module request nằm ngoài baseline, nhưng ghi chú client hiện tại nêu rõ đây không phải cơ chế dependency dành cho feature plugin và chỉ dành cho infrastructure, transport và mã assembly được sinh tự động. Hãy để trống trường này trong feature plugin. Đây là phần thay đổi nhanh nhất trong bản preview, vì vậy hãy đọc packages/client/AGENTS.md trong repository của harness vào đúng ngày bạn viết mã, không phải vào ngày bạn đọc một guide về nó.

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. Một đường dẫn filesystem trong overlay cordis.yml, được load 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ể cài một thư mục local bằng dsh plugin --profile demo add ./my-plugin để test dạng đã đóng gói mà không cần truy cập registry.

Tại sao plugin của tôi load được 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 row id của bạn không có trong output đó, plugin chưa được mount. Khi đó nguyên nhân nằm ở composition, không phải code. Nếu row có mặt, 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 sẵn sàng. Vì vậy, 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 đó. Layer này chọ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: lần lượt từng bundle theo thứ tự được liệt kê trong profile, sau đó đến patch file của profile, rồi $DSH_HOME/cordis.patch.yml, cuối cùng là overlay --patch. Layer áp dụng sau sẽ được ưu tiên.

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

Không trong 0.1.0-rc.7. Web bundle của phiên bản này đã tắt row shared hot module reload. Trong 0.2.0-rc.2, row đó được mount trong mọi profile nhưng mặc định chỉ reload cấu hình profile. Module root được bật tùy chọn thông qua list root, nên code plugin của bạn vẫn cần restart trừ khi bạn thêm module root vào list này. Dù theo cách nào, hãy thiết kế để restart nhanh: dùng một file, load qua --patch mà không cần build step, và thực hiện mọi registration qua ctx để không có dữ liệu rò rỉ từ lần chạy này sang lần chạy tiếp theo. Dùng ctx.effect() kèm disposer cho các resource mà Cordis không thể tự cleanup.