跪拜 Guibai
← Back to the summary

Building a Custom Tool for DeepSeek Harness, from Hello World to Installable Bundle

The previous article introduced the overall architecture of Harness. This article gets hands-on with code—from a minimal plugin to a model-callable tool, covering configuration, lifecycle, and packaging for release.

Prerequisites

Environment Requirements

Starting from Source

git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install

# Configure .env
echo "DEEPSEEK_API_KEY=sk-your-key" > .env

# Start Web UI to verify the environment is working
pnpm dsh web
# Open http://127.0.0.1:3080

Step 1: Minimal Plugin

The Essence of a Plugin

In Harness, a plugin is a TypeScript module that exports an apply function:

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

export const name = 'my-plugin'

export function apply(ctx: Context) {
  // Register capabilities here
}

The framework calls apply at load time, passing in ctx—everything registered through it is automatically cleaned up when the plugin is unloaded.

Creating the Project

Create a temporary project at the repository root:

mkdir -p scratch-plugin/src

Create scratch-plugin/src/my-plugin.ts:

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

export const name = 'hello-plugin'

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

Registering in cordis.yml

Create scratch-plugin/cordis.yml, replacing /absolute/path/to/deepseek-harness with the actual absolute path of the repository:

- insert:
    - id: hello
      name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'

The plugin path must be an absolute path. The patch file contributes configuration without changing the loader's module resolution base directory.

Starting Up

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

If the terminal prints [hello-plugin] plugin loaded!, it's a success.

Step 2: Developing a Tool

Replace scratch-plugin/src/my-plugin.ts with:

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}!`
    },
  }))
}

Key Points Explained

inject: ['tools']: Declares a dependency on the ctx.tools service. Cordis ensures this service is ready before calling apply. If the provider for ctx.tools is hot-replaced, this plugin is automatically disposed of and re-applied.

defineTool DSL:

Field Purpose
name Tool name visible to the model (within 64 characters, [A-Za-z0-9_-])
description Description the model uses to decide whether to call the tool
parameters JSON Schema, automatically derives args type and performs runtime validation
output.schema Schema declaration for the canonical value
output.render Converts the canonical value into a ContentBlock visible to the model
execute Actual execution logic, receives validated args + ToolExecution context

ctx.tools.register() returns a disposer: Automatically called when the plugin is unloaded, the tool is removed from the registry, and the model will no longer see it on the next request.

Running Verification

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

In the Web UI, enter: Use the greet tool to greet Ada.

The model will call greet, and the tool returns Hello, Ada!.

Tool Execution Pipeline

The tool you register goes through the complete execution pipeline:

Model returns tool_call → tools/pre-execute (waterfall)
                    → tools/execute (waterfall)
                    → your execute() function
                    → tools/post-execute (waterfall)
                    → tool/result (session event)

This means:

Tools registered via the MCP bridge and other native tools go through the exact same pipeline.

Step 3: Adding Configurable Options

Make the greeting customizable. Update the plugin:

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

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

// Define Config interface
export interface Config {
  greeting: string
  emoji: boolean
}

// Export a Schema with the same name (Cordis uses it for validation + default values)
export const Config: Schema<Config> = Schema.object({
  greeting: Schema.string().default('Hello'),
  emoji: Schema.boolean().default(true),
})

export function apply(ctx: Context, config: Config) {
  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) {
      const suffix = config.emoji ? ' 👋' : ''
      return `${config.greeting}, ${args.name}!${suffix}`
    },
  }))
}

Update scratch-plugin/cordis.yml:

- insert:
    - id: hello
      name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
      config:
        greeting: 'Hey'
        emoji: false

Design Principles

No hardcoded tunable parameters: Any parameter that might need different values in different deployments must be defined as a Config field. The test: can you change this value in cordis.yml without modifying the code?

Configuration errors should be loud: The Schema performs validation at plugin load time. Invalid configuration causes the plugin load to fail (fiber → FAILED), giving a clear error message, rather than silently behaving abnormally at runtime.

HMR Behavior

After modifying the config field in cordis.yml, Cordis will:

  1. Unload the old fiber (the disposer from ctx.tools.register executes automatically, the tool is deregistered)
  2. Create a new fiber
  3. Call apply with the new config (the tool is re-registered)

Result: Configuration changes take effect in real time without restarting the process.

Step 4: ctx.effect() for Managing External Resources

Suppose your tool needs to maintain a persistent connection:

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

export const name = 'db-query-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
  let pool: ConnectionPool | undefined

  ctx.effect(() => {
    pool = createConnectionPool({ host: 'localhost', port: 5432 })
    return () => {
      pool?.close()
      pool = undefined
    }
  })

  ctx.tools.register(defineTool({
    name: 'db_query',
    description: 'Run a read-only SQL query.',
    parameters: {
      sql: { type: 'string', required: true, description: 'SQL query' },
    },
    output: {
      schema: { type: 'array', items: { type: 'object' } },
      render: (_args, rows) => [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
    },
    async execute(args) {
      if (!pool) throw new Error('Database pool not available')
      return await pool.query(args.sql)
    },
  }))
}

The return function of ctx.effect is automatically executed in the following scenarios:

You don't need to track "when should I close the connection" yourself.

Step 5: Packaging as an Installable Bundle

Bundle File Structure

hello-plugin/
├── package.json       # Declares dsh.bundle
├── cordis.patch.yml   # Configuration layer contributed by this bundle
└── index.js           # Plugin entry point

package.json

{
  "name": "dsh-hello-plugin",
  "version": "0.1.0",
  "type": "module",
  "main": "index.js",
  "files": ["index.js", "cordis.patch.yml"],
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}

The dsh.bundle declaration tells the dsh plugin command: this is an installable composite package.

index.js

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

export const name = 'hello-plugin'
export const inject = ['tools']

export function apply(ctx) {
  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}!`
    },
  }))
}

cordis.patch.yml

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

Note: A package name is used here instead of a file path—Node's module resolution will find it among the installed dependencies.

Installing to a Profile

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

The first use initializes the profile (automatically including @deepseek-ai/dsh-base as the base layer), pnpm links the package, and appends it to dsh.profile.bundles.

Verification:

dsh --profile demo --dump-config   # You can see the hello layer
dsh --profile demo                 # Start up and observe the tool is available

Loading Order

1. Bundles in the profile.bundles list (in order)
2. The profile's cordis.patch.yml
3. $DSH_HOME/cordis.patch.yml (machine-level)
4. --patch overlay (command line)

Later-applied layers win on a line-by-line basis, and the entire config is replaced (not deep-merged).

Distribution Methods

Method Command Description
npm publish dsh plugin add your-package Pre-built, simplest
tarball dsh plugin add ./pkg-0.1.0.tgz Packaged with pnpm pack
GitHub dsh plugin add github:you/repo Requires a prepare script to build
Local dev dsh plugin add ./local-dir pnpm link

Three Plugin Forms

Function Form (Recommended for Most Scenarios)

export const name = 'my-plugin'
export const inject = ['tools']

export function apply(ctx: Context) { /* ... */ }

Object Form

export default {
  name: 'my-plugin',
  inject: ['tools'],
  apply(ctx: Context) { /* ... */ },
}

Class Form (When You Want to Provide a Service)

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

declare module '@deepseek-ai/cordis' {
  interface Context {
    myService: MyService
  }
}

export default class MyService extends Service {
  static inject = ['tools']

  constructor(ctx: Context) {
    super(ctx, 'myService')
  }

  // Other plugins call ctx.myService.doSomething()
  doSomething() { /* ... */ }
}

The class form makes your plugin a dependency for other plugins—they can inject: ['myService'] and use ctx.myService in their apply.

Service Isolation

The same service can have multiple instances, with different plugin groups seeing different instances:

- id: coding-agent
  name: '@deepseek-ai/cordis-plugin-group'
  group: true
  isolate:
    shell: true
  config:
    - name: '@deepseek-ai/dsh-bash-local'
      config:
        timeoutMs: 5000
    - name: './my-strict-tool.ts'

- id: research-agent
  name: '@deepseek-ai/cordis-plugin-group'
  group: true
  isolate:
    shell: true
  config:
    - name: '@deepseek-ai/dsh-bash-local'
      config:
        timeoutMs: 60000
    - name: './my-research-tool.ts'

Each group sees its own ctx.shell instance, and timeout configurations do not affect each other. This is a general capability of Cordis service isolation—applicable to any service like tools, shell, fs, llm, etc.

Event System

Loosely coupled communication between plugins is done through events:

export const inject = ['tools']

export function apply(ctx: Context) {
  // Listen for post-tool-execution events
  ctx.on('tools/post-execute', (toolName, result) => {
    console.log(`Tool ${toolName} executed, result length: ${result.length}`)
  })
  
  // Waterfall events must call next()
  ctx.waterfall('tools/pre-execute', async (toolName, args, next) => {
    console.log(`About to execute: ${toolName}`)
    return next()  // Must be called, otherwise the entire chain is short-circuited
  })
}

There are three types of events:

Debugging Tips

Viewing the Final Configuration Tree

pnpm dsh web --dump-config

Prints the complete plugin tree after all layers are merged—confirm whether your plugin is loaded correctly.

Checking Service Registration

In the plugin:

export function apply(ctx: Context) {
  console.log('Available services:', Object.keys(ctx.root))
}

HMR Development Loop

  1. Start pnpm dsh web --patch ./scratch-plugin/cordis.yml
  2. Modify scratch-plugin/src/my-plugin.ts
  3. Save—observe the logs in the terminal for the old plugin unloading + new plugin loading
  4. Immediately use the new behavior in the Web UI

No need to restart the process.

Advanced Directions

Direction Documentation
Three-layer capability split develop/practice
LLM Adapter develop/practice/llm-adapter
Event System develop/framework/events
Cordis Framework Tutorial develop/cordis-tutorial
Tool Writing Reference reference/cookbook/adding-a-tool

Reference Links


Previous: DeepSeek Harness: An Open-Source AI Agent Runtime Where Everything is a Plugin

Next: How MCP and Skills are Unified as Cordis Plugins