This tutorial walks you through building a tool for DeepSeek Harness: declare a parameter schema, execute, and output rendering with defineTool, register it with ctx.tools.register, and let the model call your code in conversation. Tools are the most model-facing kind of plugin capability — once you finish, the model gains a new skill.

Tool anatomy: defineTool declares the tool fields, ctx.tools.register registers it with the tools service, and after the model calls it, output.render converts the result into content blocksdefineTool({})name: 'greet'description: …parameters: { name: string }execute(args) {}output.schema: …output.render(value) {}Fields are a contract: the model reads description to decide when to callexecute returns the canonical value; render turns it into blocksThe model in conversationDecides when to call and what args to passregisters via ctx.tools.registeroutput.render converts the result
Tool anatomy: defineTool declares the fields, ctx.tools.register registers the tool, and output.render returns content blocks after the model calls it.

Prerequisites

Before you start, make sure:

  • you have completed Build Your First Plugin: scratch-plugin/ exists at the repo root and cordis.yml already registers src/my-plugin.ts;
  • pnpm dsh web --patch ./scratch-plugin/cordis.yml launches the web UI;
  • you understand the three exports name, inject, and apply.

How a tool works

A tool lets the model call your code during a conversation. A regular plugin loads passively at startup and does background work; a tool exposes a capability to the model in a structured way — the model decides when to call it and what arguments to pass, then folds the result into its reply.

Tool call flow: after the user asks, the model picks a tool by its description, execute runs with validated args, render converts the result, and the model replies1User asksModel reads the tool list2Model decidesPicks a tool by description3execute runsArgs validated by schema4render convertsValue to content blocks5Model repliesAnswers from the resultexecute’s return value never enters the chat directly — output.render decides what the model sees
The full path of one tool call: decide, execute, render, reply.

A tool call has five steps: the model reads the tool’s name and description and decides whether the current question needs it; if so, it assembles arguments per the parameters schema; the framework validates them and hands them to execute; execute’s return value is converted to content blocks by output.render; the model reads the result and writes its final reply.

Write the tool plugin

Open scratch-plugin/src/my-plugin.ts and replace its contents with:

scratch-plugin/src/my-plugin.ts (full replacement)
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}!`
    },
  }))
}

Compared with the first plugin, the skeleton is unchanged: it still exports the name, inject, apply trio. What changed is that apply now registers a tool defined with defineTool via ctx.tools.register — this is the star of the tutorial.

Understanding defineTool field by field

Every field of defineTool maps to one link in the chain. Let’s go through them:

name and description: a manual written for the model

name is the tool’s call identifier — the model references it by name in tool calls, so a lowercase verb phrase such as greet or search_docs is clearest. description is usage guidance written for the model: it decides “when should I use this tool” from this text. Describe the scenario, not the implementation.

parameters: the argument contract

parameters declares each argument: type is one of string, number, boolean; required marks it mandatory; description is shown to the model as well. defineTool infers and validates args against this schema at runtime, so execute receives typed, validated arguments.

output and execute: separate execution from presentation

execute is the tool’s actual logic and returns the canonical value declared by output.schema — a string here. output.render converts that value into model-readable content blocks, e.g. [{ type: "text", text: value }]. Splitting execution from presentation means execute only cares about computing the right result, while how it is presented can evolve independently.

Why you must inject tools

The tools service is a built-in Harness service — a plugin must declare the dependency before using it safely:

inject is a hard requirement
export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
  // the tools service is ready — ctx.tools is guaranteed to exist
  ctx.tools.register(defineTool({ /* ... */ }))
}

Restart and verify

With the plugin code in place, restart to load it:

Restart with the tool loaded
pnpm dsh web --patch ./scratch-plugin/cordis.yml
# open http://127.0.0.1:3080

Type this into the chat box:

Try this prompt
Use the greet tool to say hi to Ada.

The model recognizes that greet fits, calls it with name: 'Ada', receives Hello, Ada!, and works it into its reply. Seeing the greeting in the reply means the whole tool chain works.

Writing a good description

Whether a tool gets used at all comes down to its description. On every turn the model reads the name and description of every registered tool and decides from them. Compare two styles:

description compared
// bad: implementation detail — the model cannot tell when to use it
description: 'calls the greet function and returns a string'

// good: usage guidance — the model knows when to reach for it
description: 'Greet someone by name when the user asks to say hello.'

Each parameter’s description is shown to the model too — the clearer the argument, the less likely the model passes the wrong value. Treat the model as a new teammate: explain what the tool does, when to use it, and how to fill in each argument, and it will use the tool correctly.

Iterate and debug

Tool development is a fast iteration loop:

The iteration loop
# 1. edit scratch-plugin/src/my-plugin.ts
# 2. restart
pnpm dsh web --patch ./scratch-plugin/cordis.yml
# 3. verify at http://127.0.0.1:3080 with different phrasings

One plugin can register multiple tools — call ctx.tools.register as many times as you like. This is a natural way to ship several capabilities from one domain together.

Tool development FAQ

The model never calls my tool — what now?

First check whether description explains the usage scenario clearly — the model decides from it. Then make sure the tool name is intuitive and each parameter’s description is explicit. You can explicitly prompt “use the greet tool to say hi to Ada” to verify the tool itself works, then gradually remove the hint to test the model’s autonomous choice.

What happens if execute throws?

The error is returned to the model as a failed tool call. The model typically retries, tries different arguments, or tells the user the call failed. During development, log inside execute and restart to pinpoint the issue quickly.

What does output.schema declare?

It declares the canonical type of execute’s return value (a string in this example). render converts that value into model-readable content blocks. The schema is a contract for both the framework and the model: the framework knows the shape of the result, and the model knows what it will receive.

Can one plugin register multiple tools?

Yes. Call ctx.tools.register multiple times inside apply, once per defineTool definition. Shipping several tools from one domain in a single plugin is a common pattern.

Do I have to restart after editing tool code?

Yes. The plugin module is loaded once at startup — rerun pnpm dsh web --patch ./scratch-plugin/cordis.yml after each edit. During development the patch only contributes config and never touches your global profile, so restarting is side-effect free.