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
- Node.js ^22.19 or >=24
- pnpm (
packageManager: "[email protected]") - DeepSeek API Key (required for actual execution)
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:
- Permission policies can intercept your tool at
tools/pre-execute - Approval mechanisms can require user confirmation before execution
- Timeouts/cancellation are communicated via
ToolExecution.signal - Results are automatically recorded in the session log
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:
- Unload the old fiber (the disposer from
ctx.tools.registerexecutes automatically, the tool is deregistered) - Create a new fiber
- Call
applywith 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:
- The plugin is manually disposed
- A dependent service disappears (provider hot-replacement)
- HMR triggers a configuration change
- The entire application shuts down
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:
- emit: Broadcast notification, listeners do not affect the flow
- serial: Executed in registration order, no
next() - waterfall: Chained passing, must call
next()to proceed, can intercept or rewrite in the middle
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
- Start
pnpm dsh web --patch ./scratch-plugin/cordis.yml - Modify
scratch-plugin/src/my-plugin.ts - Save—observe the logs in the terminal for the old plugin unloading + new plugin loading
- 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
- Official Docs: First Plugin
- Official Docs: Developing a Tool
- Official Docs: Plugin Configuration
- Official Docs: Packaging and Installation
- Official Docs: Plugins and Lifecycle
Previous: DeepSeek Harness: An Open-Source AI Agent Runtime Where Everything is a Plugin