How to Add a Custom Tool to DeepSeek-Harness with defineTool
A field-by-field guide to defineTool: parameters, output.schema, output.render, execute, the inject dependency it needs, and how Code Mode affects it.
A DeepSeek-Harness (dsh) tool is registered by calling ctx.tools.register(defineTool({...})) inside a plugin's apply(ctx), where defineTool comes from @deepseek-ai/dsh-tools. This post walks through every field defineTool takes — parameters, output.schema, output.render, execute — and the one dependency (inject: ['tools']) your plugin needs to call it at all.
The minimal tool
Here's the official example, from the dsh docs' own tool tutorial, unmodified:
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}!`
},
}))
}
Five things happen here: the plugin declares it needs the tools service (inject), receives ctx once that service exists, calls ctx.tools.register() with the object defineTool builds, and defineTool itself wires together a name, a description, an input shape, an output shape, and an implementation. The rest of this post takes those pieces one at a time.
inject: ['tools'] — why it's not optional
ctx.tools doesn't exist until the tools service is loaded. Declaring inject: ['tools'] tells the Cordis framework two things: don't call apply until that service is ready, and unload this plugin automatically if the service ever goes away. Skip the declaration and your plugin might load before ctx.tools exists, depending on load order — inject removes that race by making the dependency explicit rather than assumed. If you want the mechanics of how injected dependencies gate a plugin's lifecycle, see Services, Dependency Injection, and Plugin Lifecycle in DeepSeek-Harness.
name and description
Plain strings. name is what the model sees as the callable function name; description is what tells the model when to reach for it. Neither is validated beyond being present — but both flow directly into prompt assembly, so treat description as the one sentence that decides whether your tool ever gets called at all.
parameters — the input shape
Each key in parameters is one argument, described with a type, whether it's required, and a human-readable description:
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
defineTool uses this shape to validate incoming arguments before your execute function ever runs — a call missing a required parameter, or sending the wrong type, doesn't reach your code. A second, optional parameter follows the identical shape:
parameters: {
text: { type: 'string', required: true, description: 'The text to count words in' },
caseSensitive: { type: 'boolean', required: false, description: 'Whether casing affects the count' },
},
The tool tutorial's own example only shows a single required string parameter, so treat anything beyond that basic type / required / description shape — nested objects, arrays, enums — as a question for the deeper reference rather than something to guess at: docs/cookbook/adding-a-tool.md is the official cookbook the tool tutorial itself points to for advanced parameter and schema patterns.
output.schema and output.render — two different jobs
This is the field most people misread on first pass, because it looks like one thing and is actually two:
output.schemadescribes the canonical value yourexecutefunction returns — the raw data shape, independent of how it's displayed.output.renderis a function(args, value) => content[]that turns that canonical value into the content blocks the model actually sees in its context.
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
In the minimal example both are trivial — the canonical value is the string, and rendering it is just wrapping it in a { type: 'text', text: value } block. The split matters more once a tool's real output is structured data (an object, a list) that needs a specific textual presentation, or — per the same cookbook reference above — richer content like UI cards; the tool tutorial establishes the schema/render split as the mechanism, without our review covering every content-block type it supports.
execute — the actual work
async execute(args) {
return `Hello, ${args.name}!`
},
args arrives already validated against parameters — by the time your code runs, required fields are present and typed. execute is async, so anything from a simple string transform to an HTTP call or a filesystem read is a normal await away. The return value must match output.schema, since that's the value output.render receives next.
A worked second example
Putting all four fields together for a slightly less trivial tool — counting words in a string:
import { defineTool } from '@deepseek-ai/dsh-tools'
ctx.tools.register(defineTool({
name: 'word-count',
description: 'Count the words in a piece of text.',
parameters: {
text: { type: 'string', required: true, description: 'The text to count' },
},
output: {
schema: { type: 'number' },
render: (_args, value) => [{ type: 'text', text: `${value} words` }],
},
async execute(args) {
return args.text.trim().split(/\s+/).filter(Boolean).length
},
}))
execute returns a plain number matching output.schema; output.render turns that number into a sentence the model reads back as tool output.
Naming — what's documented and what isn't
The tool tutorial doesn't state a general naming convention for defineTool names beyond "pick a string." The one naming rule dsh documents explicitly applies to a different, related surface: tools bridged in from MCP servers get the name mcp__<serverName>__<rawName>, normalized to 64 characters and [A-Za-z0-9_-], with a hash suffix if two names collide — see How to Use MCP Servers with DeepSeek-Harness. Whether that same character-and-length constraint applies to a plain defineTool name isn't something the docs we reviewed spell out, so don't assume it without checking docs/user/develop/basic/tool.md yourself if you're picking an unusual name.
Code Mode and your tool
dsh supports a DSH_TOOLS_MODE environment variable with three values: native (ordinary function-calling), code ("Code Mode," where the model writes code that calls your tool instead of issuing a native function call), and both. This setting changes how the model invokes tools, not how you define one — a tool registered with defineTool works the same way regardless of which mode the deployment runs in, because the translation between "code that calls this tool" and "a native function call to this tool" happens below the defineTool layer.
FAQ
Do I need to write my own JSON Schema for parameters?
No — defineTool's parameters object uses its own shorthand (type, required, description per field), not raw JSON Schema. defineTool derives argument validation from that shorthand for you.
What happens if a required parameter is missing?
execute never runs. Validation against parameters happens before your function is called, so you don't need to hand-check args for required fields yourself.
Can execute throw an error?
The tool tutorial doesn't document specific error-handling conventions for execute, and this article doesn't invent one — if you need retry, timeout, or approval-gated behavior, the cookbook (docs/cookbook/adding-a-tool.md) is the pointer the official docs give for that territory.
Is output.render required, or can I skip straight to a string?
The documented shape always pairs schema with render — there's no shortcut shown in the official example for omitting one or the other.
Next steps
If you haven't wired up the surrounding plugin yet, start with Build a DeepSeek-Harness Plugin from Scratch. Once your tool works, make its behavior configurable without hardcoding values in Making Your DeepSeek-Harness Plugin Configurable with Schemastery. To see a real, high-star tool plugin in the wild, browse modlens or the rest of Tools & Capabilities on FindHarness.