Making Your DeepSeek-Harness Plugin Configurable with Schemastery
How to export a Config schema from a DeepSeek-Harness plugin, set values through cordis.patch.yml, and why a patch's config field replaces rather than merges.
A DeepSeek-Harness (dsh) plugin declares its user-configurable options by exporting a Config value — a Schemastery schema. dsh validates whatever the loading cordis.patch.yml puts in that row's config field against your Config schema and fills in defaults for anything left unset, before your apply(ctx, config) function ever runs.
Why plugins need this at all
The design principle behind Config is stated plainly in dsh's own plugin docs: if two deployments of the same plugin might reasonably want different values for something, that something has to be a configuration option — not a hardcoded constant in your source. A greeting prefix, a timeout, an API base URL, a feature flag: all things a Config schema exists to make adjustable without forking your plugin's code.
Declaring Config in a plugin
import type { Context } from '@deepseek-ai/cordis'
import { Schema } from 'schemastery'
export const name = 'greet-tool'
export const inject = ['tools']
export interface Config {
greeting: string
}
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string()
.default('Hello')
.description('The word used to greet someone.'),
})
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) {
return `${config.greeting}, ${args.name}!`
},
}))
}
apply now takes a second parameter, config — the validated, defaulted object dsh builds from your Config schema and whatever the active cordis.patch.yml row set. Note the export is named Config twice, once as a TypeScript type and once as the runtime schema value — that pairing (type + same-named schema) is how dsh's own plugin docs describe "this plugin has configurable options." The exact module Schema is imported from isn't spelled out in what we reviewed of the official tool tutorial — schemastery is the library's own npm package name, so that's the safe, verifiable import; if dsh's own docs re-export it through @deepseek-ai/cordis instead, check docs/user/develop/basic/config.md for the convention they actually use.
Schema.object, Schema.string, .default, .description
Schema.object({...}), Schema.string(), .default(...), and .description(...) are Schemastery's own general-purpose API — the same library dsh uses, not something dsh adds on top of it. Schema.object describes a record of named fields, each field is built from a base type like Schema.string(), and .default() / .description() chain onto any schema to set a fallback value and a human-readable label respectively. If you need types beyond string (numbers, booleans, nested objects, unions), that's ordinary Schemastery surface — refer to the Schemastery repository directly rather than assuming dsh has extended or restricted it, since the docs we reviewed don't enumerate a dsh-specific subset.
Setting values through cordis.patch.yml
The schema only defines what's valid — actual values come from the config field on the plugin's row in a cordis.patch.yml:
- insert:
- id: greet-tool
name: 'dsh-greet-plugin'
config:
greeting: 'Howdy'
That value flows into apply(ctx, config) as config.greeting. Leave config off the row entirely, and Schemastery's .default('Hello') fills it in instead — your plugin's apply function never has to check config.greeting ?? 'Hello' itself.
The full-replace trap applies to your Config, too
This is the detail that catches plugin authors specifically, not just profile operators: a later layer that overrides your plugin's row by id replaces that row's entire config object, not just the fields it mentions. If your Config schema has two fields and a machine-level cordis.patch.yml overrides the row with only one of them set:
- id: greet-tool
config:
greeting: 'Yo'
the other field reverts to whatever your Config schema's own .default() says — not to whatever value a lower layer (say, the profile's own patch) had set it to. Schemastery defaults are a safety net against a field being absent entirely; they don't protect against a higher layer's override wiping out a lower layer's explicit value for a sibling field. cordis.patch.yml Explained covers the full four-layer loading order this interacts with, and the configuration guide covers debugging it with --dump-config.
Adding a second field
Real plugins rarely stop at one option. Extending the example above with a boolean flag follows the same pattern — each field gets its own Schema.*() call, with .default() and .description() chained on as needed:
export interface Config {
greeting: string
enthusiastic: boolean
}
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string()
.default('Hello')
.description('The word used to greet someone.'),
enthusiastic: Schema.boolean()
.default(false)
.description('Append an exclamation mark to the greeting.'),
})
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 punctuation = config.enthusiastic ? '!' : '.'
return `${config.greeting}, ${args.name}${punctuation}`
},
}))
}
A user (or another cordis.patch.yml layer) can now set either field, both, or neither — anything unset falls back to its own .default() independently, as long as the row's config is set at all. Remember the full-replace rule below: if a later layer sets config on this row, it has to restate every field it wants to keep, not just the ones it's changing.
Two different configuration surfaces: Config vs. tool parameters
It's easy to conflate this with the parameters object inside defineTool, but they answer different questions. A plugin's Config schema is set once, by whoever deploys the plugin (via cordis.patch.yml), and stays fixed for the life of that plugin instance — it's deployment-time configuration. A tool's parameters are supplied by the model on every single call, and change from one invocation to the next — it's call-time input. In the example above, greeting and enthusiastic are Config because an operator sets them once; name is a tool parameter because the model supplies a different one each time greet is called. See How to Add a Custom Tool to DeepSeek-Harness with defineTool for the full breakdown of the parameters side.
What happens if a config value fails validation
The tool tutorial establishes that dsh validates the config field against your schema at load time and fills defaults, but the specific failure behavior when a value is present and simply wrong for the schema — reject the whole plugin, log and fall back to defaults, or something else — isn't detailed in what we reviewed. Don't rely on a specific failure mode without checking docs/user/develop/basic/config.md directly if that distinction matters for your plugin.
FAQ
Does Config have to be an object schema?
Every documented example wraps options in Schema.object({...}), matching how config: is written in YAML as a mapping of named fields. That's the pattern to follow unless you have a specific reason to deviate.
Can I read config outside of apply?
apply(ctx, config) is where the validated config is handed to your plugin. The tutorial doesn't document a separate accessor for reading it elsewhere in your module — pass it explicitly to any function that needs it.
Do I need Config if my plugin has no options?
No — Config is optional. A plugin that only exports name and apply is valid; you only add a Config schema once there's actually something worth making adjustable per deployment.
Is Schemastery specific to dsh?
No — Schemastery is a general-purpose schema library dsh depends on, not something dsh authored. Its full API surface (beyond Schema.object, Schema.string, .default, .description) lives in its own repository and documentation, independent of dsh.
Next steps
See how a Config-bearing plugin's row actually gets written and layered in cordis.patch.yml Explained, and how the four config layers combine and get debugged in DeepSeek Harness Configuration Guide. If you're building the surrounding plugin from zero, start at Build a DeepSeek-Harness Plugin from Scratch, or browse real, shipped examples in Development & Runtime on FindHarness.