DeepSeek Harness plugin: build a model tool with defineTool
Building a tool plugin in a DSH plugin project is a fixed three-step recipe: export inject: ['tools'], call ctx.tools.register(defineTool({ ... })) inside apply, and declare parameters (what the model may pass), output.schema (what execute must return), and execute (how it runs). Every DeepSeek Harness plugin that exposes a tool follows the same contract.
The minimal DSH plugin tool: declaration plus registration
A tool plugin is a dependency declaration plus a registered tool definition, and registration is effect-based — it unregisters when the plugin unloads. Here is the official minimal form (source):
import { readFile } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.',
parameters: {
path: { type: 'string', required: true, description: 'Absolute path' },
limit: { type: 'number' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))
}
inject: ['tools'] is not optional — it makes Cordis prepare the tool registry first, so apply can safely use ctx.tools. The full dependency mechanism is covered in how to write a plugin.
parameters and output in a DSH plugin tool: one in, one out
parameters governs input and output.schema governs output; the two responsibilities do not overlap. The official boundary is that defineTool infers and validates execute's args from parameters, while execute returns the canonical value declared by output.schema, which output.render turns into model-facing content (source).
- Arguments are validated for you: types, required keys, literal constraints, exact-one unions, and nested values are all checked before
execute— you never write type guards, but constraints the DSL cannot express (non-empty strings, positive numbers, cross-field rules) are yours to check. descriptionis written for the model: it decides when the model calls the tool; it is not a comment.- Output declares exactly one root value: object, array, scalar, or null — pick the honest shape of the value rather than stuffing UI prose into the schema.
The DSH plugin execute contract: four hard rules
execute is a contract, not an ordinary function — crossing these four lines causes problems.
- Return exactly one canonical value. Do not return content blocks or make callers parse prose for ids and fields; the registry snapshots it as lossless JSON, validates it, and freezes it.
- Throwing means
isError. Throw for infrastructure failures (missing file, dropped connection), and return the canonical value for a non-ideal domain outcome — a non-zero process exit is a normal return value the renderer explains, not an exception. - Honor
exec.signal. Cancel in-flight work when it fires;execalso carries immutable execution identity and a token, andargsshould be treated as read-only input. - Do not mutate your definition after registering. A typed same-process contribution is not a serialization boundary; to hot-swap a tool, dispose its owning effect and register a replacement.
Do not build policy or telemetry into the tool body. The official guidance is to use extension points instead: tools/pre-execute (allow/deny/ask policy), ctx.tools.guard() (a final, non-revocable denial), tools/execute (wrap dispatch with deadlines, retries, metrics), tools/post-execute (replace presentation content or the value), and tools/result (observe the immutable outcome).
Long-running work and DSH plugin UI cards
Route slow operations through the background-job channel and leave UI rendering to pure card functions — neither should pollute the canonical value. When you need run_in_background, register via ctx.jobs.start({ kind, label, owner: exec.agent, run }) and return a typed handle such as { kind: 'background', jobId } on the success branch. Code Mode must never parse the human-readable started background job bash-1 line to recover the id (source).
Declare UI cards through two optional methods that return a card-tagged render intent:
| Method | Card | When it fits |
|---|---|---|
presentCall(args) | terminal | The call itself is a shell command |
presentCall(args) | diff | The call creates or modifies a file |
presentCall(args) | generic | The default card; supports a kind icon and locations |
presentResult(args, result) | same kinds | The completed state: terminal output, applied diffs, search results |
Two hard rules bite if broken: ① these functions run on live streaming and session replay, so they must be pure functions of args (and the result) — no I/O, no session state, no clock or randomness; ② UI-only formatting must stay out of the model result — output.render owns model-facing prose, while presentationMeta plus card presenters own UI state.
A tool with no UI presentation falls back to a generic card (title = tool name, input = raw args) instead of crashing. For wider UI work, see plugin UI development.
DSH plugin tool checklist and next steps
Run these five checks before registering:
- Is
toolsininject? - Do
parametersandoutput.schemadescribe the contract clearly, with a model-facingdescription? - Does
executereturn only the canonical value, and does it distinguish a throw from a non-ideal result? - Does it honor
exec.signal? - Are the card presenters pure functions?
Split packages only when the implementation must be replaceable: separate the definition (Service Definition), the implementation (Provider), and the consumer (the tool) — see the three-role design in the development guide. For distribution, see packaging into a bundle and publishing to the plugin hub. After installing, confirm the state in the installed list of DSH Plugin Hub.
FAQ
**A DSH plugin registers a model-callable tool in three steps: ① export inject = ['tools'] so the framework prepares the tool registry first; ② call ctx.tools.register(defineTool({ ... })) inside apply; ③ declare parameters, output.schema, and execute in defineTool.** Registration is effect-based, so disposing the plugin fiber unregisters the tool (source: official "Build a tool").
**In a DSH plugin's defineTool, parameters governs input and output.schema governs output, and the two must stay separate.** parameters describes what the model may pass, and defineTool infers and validates the args type from it; output.schema describes the single canonical value execute must return, which output.render turns into model-facing content (source: official "Tool authoring reference").
**No — a DSH plugin's execute returns only the canonical JSON value declared by output.schema**, and the registry snapshots, validates, and freezes it before handing it to output.render(args, value). The official reference says not to return content blocks from the body or make callers parse prose for ids and fields (source: official "Tool authoring reference").
**A DSH plugin tool follows two error rules: throw for infrastructure failures (the registry catches them and marks isError), and return the canonical value for non-ideal domain outcomes**, leaving the renderer to explain them — a non-zero process exit is a good example. Also honor exec.signal, which must cancel in-flight work when it fires (source: official "Tool authoring reference").
**A DSH plugin customizes its UI card by returning a card-tagged render intent from presentCall(args) and presentResult(args, result)**: use terminal when the call is a shell command, diff when it creates or modifies a file, and generic otherwise (optionally with a kind icon and locations to jump). These run on live streaming and on session replay, so they must be pure — no file reads, session state, or clock (source: official "Tool authoring reference").
Related Terms
- defineTool
- defineTool declares a model-callable tool in a DSH plugin. It takes name, description, parameters, output, and execute, returns a definition you pass to ctx.tools.register, and infers the execute arguments from parameters.— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-tool.md
- canonical value
- The canonical value is the single structured result a DSH plugin tool's execute returns per output.schema. The registry snapshots it as lossless JSON, validates and freezes it, then passes it to output.render to produce model-facing content.— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-tool.md
- exec.signal
- exec.signal is the cancellation signal in a DSH plugin tool's execution context; in-flight work must stop when it fires. It is part of the protected execution identity and cannot be removed by callers or wrappers.— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-tool.md
- presentCall / presentResult
- presentCall and presentResult are optional UI projections of a DSH plugin tool that return card render intents (generic, terminal, diff, search, web) for the pending and completed states. They must be pure functions of the args and result.— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-tool.md