This tutorial shows how to make a DeepSeek Harness plugin configurable: declare a Config schema with Schemastery, pass config in cordis.yml, let the framework validate input and fill defaults at load time, and receive type-safe config directly in apply. It ends with hot-replace semantics and two design principles for plugin configuration.

Plugin config pipeline: declare the schema, pass config in cordis.yml, validate at load time, fill defaults, and read type-safe config in apply1Declare schemainterface + rules2Values in ymlconfig map3Load-time checktypes & enums4Fill defaultsmissing fields5Read in applytype-safe configValidation and default-filling both happen at load time, so apply always receives a complete, valid config.
The five-step config pipeline: the schema declares the contract, validation and defaults happen at load time, and apply only ever consumes valid config.

Why configuration matters

Start with a plugin that has no configuration — the greeting is hardcoded:

A hardcoded greeting
export const name = 'greeter'

export function apply(ctx: Context) {
  console.log('[greeter] Hi')
}

The problem shows up quickly: your dev setup wants Hi, production wants Hello from production, and another deployment wants it in Chinese. Every new value means editing code and shipping a release — values like these should not be decided by the plugin author.

Configuration is the answer: anything two deployments may want to set differently becomes a config field, filled in by the operator in cordis.yml. A simple test: can cordis.yml change it without editing code? If yes, it belongs in config.

Define the Config interface and schema

Harness’s convention: a plugin exports both a TypeScript interface named Config and a same-named Schemastery schema. The interface serves the compiler and editor; the schema serves the framework at runtime — validating user input and filling defaults at load time:

src/greeter.ts
import { Schema, type Context } from '@deepseek-ai/cordis'

export interface Config {
  greeting: string
  maxRetries: number
  mode: 'fast' | 'accurate'
}

export const Config: Schema<Config> = Schema.object({
  greeting: Schema.string().default('Hi'),
  maxRetries: Schema.number().default(3),
  mode: Schema.union(['fast', 'accurate']).default('fast'),
})

export const name = 'greeter'

export function apply(ctx: Context, config: Config) {
  // config is validated, with defaults filled
}

The shared name is no accident: TypeScript’s type space and value space are independent, so interface Config and const Config coexist. When the framework loads the plugin, it finds the Config schema and validates the config passed from cordis.yml against it; the second argument of apply is the validated result, typed as Config — type-safe end to end.

Schema types and modifiers

Schemastery offers constructor functions for field types, with chained modifiers for constraints:

Schema constructors and modifiers
Schema.string()                    // string
Schema.number()                    // number
Schema.boolean()                   // boolean
Schema.union(['fast', 'accurate']) // enum-like values

Schema.string().default('Hi')      // optional: falls back to 'Hi'
Schema.string().required()         // required: missing value fails the load

Schema.object({ ... }) is the root container; each key maps to a field of the Config interface. Schema.union suits “pick one of these” enums — it restricts the value range at runtime and gives the union type in the interface something to stand on.

Pass config in cordis.yml

Fill in the config map of the registration entry; the framework hands it to the schema at load time:

scratch-plugin/cordis.yml
- insert:
    - id: greeter
      name: /abs/path/to/scratch-plugin/src/greeter.ts
      config:
        greeting: 'Hi there'
        maxRetries: 5

Only greeting and maxRetries are set here; mode is omitted — the schema fills its default 'fast' at load time. In other words, the config map only needs the fields you want to override.

Read config in apply

The second argument of apply, config, is the validated config: correct types, defaults filled, illegal values already rejected. Just read the fields:

Reading the validated config
export function apply(ctx: Context, config: Config) {
  console.log('[greeter] ' + config.greeting + ' (mode=' + config.mode + ')')

  for (let i = 0; i < config.maxRetries; i++) {
    // retry logic bounded by config.maxRetries
  }
}

No defensive code needed: no checking whether config.greeting is undefined, no manual type assertions. If the user passed an illegal value, the plugin never reaches apply — the load fails first.

Hot-replace: no restart needed

The smoothest part of the development loop: edit config in cordis.yml and the framework hot-replaces the plugin — unloading the old instance and loading a fresh one, without restarting all of Harness:

Editing config triggers a hot-replace
# change maxRetries from 5 to 10 and save — a hot-replace fires
config:
  greeting: 'Hi there'
  maxRetries: 10

Why does hot-replace leave no stale state behind? Because a plugin’s registrations — listeners, timers, commands — are lifecycle effects that clean themselves up on unload. The old instance runs its cleanup through UNLOADING while the new one reloads from PENDING, and the two never interfere:

Plugin fiber state machine: PENDING to LOADING to ACTIVE to UNLOADING to DISPOSED, with ACTIVE forking to FAILED on invalid configPENDINGWaitingLOADINGValidatingACTIVERunningUNLOADINGCleaning upDISPOSEDDisposedinvalid configFAILEDload abortedEditing config in cordis.yml triggers a hot-replace: the old instance cleans up through UNLOADING while a fresh one reloads from PENDING.
The plugin fiber state machine: a hot-replace is the old instance cleaning up through UNLOADING plus a fresh reload from PENDING.

Defaults and validation failures

The schema does two things at load time: fill defaults and validate input. The first keeps config complete; the second makes errors surface early. For example, mode only accepts 'fast' or 'accurate' — passing 'turbo' fails the load outright:

A load-time validation error (illustrative)
config validation failed for "greeter":
  mode: expected one of "fast", "accurate", but got "turbo"

This is the “fail loudly” principle: express self-contained constraints in the schema so invalid config fails at load time with an actionable error — instead of blowing up at 2 a.m. in some log with a mysterious undefined.

Design principles and pitfalls

Two principles run through this tutorial:

  • No hardcoded tunables: anything two deployments may want to set differently must be a config field. Ask the test question — can cordis.yml change it without editing code?
  • Fail loudly: put constraints in the schema so invalid config fails at load time with an actionable error message.

The most common pitfall is exporting Config as a plain object:

Config must be a schema
// wrong: a plain object is never validated and never gets defaults
export const Config = {
  greeting: 'Hi',
}

// right: a Schemastery schema implementing the Standard Schema interface Cordis expects
export const Config: Schema<Config> = Schema.object({
  greeting: Schema.string().default('Hi'),
})

Plugin configuration FAQ

Why do the Config interface and the Config constant share a name?

TypeScript keeps the type space and value space separate, so the same name is allowed. The interface Config exists only at compile time for typing; the const Config is a runtime Schemastery schema that validates input and fills defaults. Sharing the name is the official Cordis convention — the framework finds your schema through it.

What if I pass no config at all in cordis.yml?

Every field falls back to the default declared in the schema, and the plugin loads normally. But if a field is marked .required() with no default, loading fails immediately and reports the missing field.

Do I need to restart Harness after changing config?

No. Editing config in cordis.yml triggers a hot-replace: the framework unloads the old plugin instance and loads a fresh one, with registrations cleaned up automatically by the lifecycle. Restarting with --patch works too and never touches your global profile.

Can I export Config as a plain object?

No. Config must be a Schemastery schema — it implements the Standard Schema interface Cordis expects. A plain object is never validated and never gets defaults filled, so apply would receive raw, unprocessed input.