Skip to main content
All posts
Guide

DeepSeek Harness Configuration Guide: Patches, Layers, --dump-config

How DeepSeek Harness merges its four config layers, why a patch replaces a row instead of deep-merging it, and how to debug the result with --dump-config.

DeepSeek-Harness (dsh) configuration is a stack of YAML patches applied in a fixed order — bundle patches, then your profile's patch, then a machine-wide patch, then any --patch flags — and each layer replaces the target row's config wholesale rather than merging it field by field. That one rule explains most of the config surprises people hit.

The four layers, in order

Every config change lives in a cordis.patch.yml-shaped file: a YAML array where each entry either inserts new rows into the plugin tree or overrides an existing row by id. dsh combines four of these in sequence:

#LayerWhere it livesScope
1Bundle patchesInside each installed npm package (dsh.bundle.patch)Whatever that bundle contributes, in dsh.profile.bundles order
2Profile patch$DSH_HOME/profiles/<name>/cordis.patch.ymlThis profile only
3Machine patch$DSH_HOME/cordis.patch.ymlEvery profile on this machine
4Command-line patches--patch <path>, repeatableThis invocation only

Background on where profiles and bundles come from, and why web/headless are special-cased, is covered in Profiles and Bundles in DeepSeek Harness, Explained. What matters here is the merge behavior once all four layers are in play.

insert vs override-by-id

A patch entry either adds new rows or targets an existing one:

# insert a new plugin row
- insert:
    - id: my-tool
      name: '/absolute/path/to/my-tool.ts'
      config:
        greeting: hello

# override an existing row's config, by id
- id: my-tool
  config:
    greeting: goodbye

The id is the join key across every layer. If a bundle inserts a row with id: dsh-shell and your $DSH_HOME/cordis.patch.yml later has an entry with the same id, your machine-level entry wins — because layer 3 applies after layer 1.

The rule that trips people up: full replace, not deep merge

When a later layer targets a row by id, it replaces that row's entire config block — it does not deep-merge your override into the existing object. If a bundle ships:

- id: my-tool
  config:
    greeting: hello
    timeoutMs: 5000

and you write an override that only touches greeting:

- id: my-tool
  config:
    greeting: goodbye

the result is { greeting: goodbye }timeoutMs is gone, not preserved. To override one field safely, you have to restate every field the row's config needs, not just the one you're changing. This is the single most common source of "my override broke something else" bug reports, and it's a direct consequence of how the patch layering is designed, not a bug.

Where the machine-level layer actually helps

$DSH_HOME/cordis.patch.yml applies to every profile you run on that machine, above each profile's own patch (layer 3 beats layer 2). That makes it the right place for settings you want everywhere regardless of which profile you launch — a permission override, a shared plugin config value, a directory-picker backend swap — instead of copy-pasting the same override into every profile's own patch file. One concrete real-world use of this layer: the documented workaround for a broken native directory picker on Windows is disabling directory-picker and inserting directory-picker-browse at the machine level, so it applies no matter which profile you're in.

A worked example across all four layers

To make the ordering concrete, trace one setting through all four layers. Say a bundle inserts a shell tool with a default timeout:

# layer 1 — inside an installed bundle
- insert:
    - id: dsh-tool-bash
      name: '@some-org/dsh-bash-tool'
      config:
        timeoutMs: 5000
        allowNetwork: false

Your profile's own patch tightens the timeout for that specific profile:

# layer 2 — profile's cordis.patch.yml
- id: dsh-tool-bash
  config:
    timeoutMs: 2000
    allowNetwork: false

Your machine-level patch — because you've decided every profile on this laptop should allow outbound network from shell commands — overrides it again:

# layer 3 — $DSH_HOME/cordis.patch.yml
- id: dsh-tool-bash
  config:
    timeoutMs: 2000
    allowNetwork: true

The resulting config for dsh-tool-bash in any profile you run on this machine is { timeoutMs: 2000, allowNetwork: true } — layer 3's row entirely replaced layer 2's, which had already entirely replaced layer 1's. Note that layer 3 had to restate timeoutMs: 2000 even though it only cared about changing allowNetwork — omitting it would have silently reset the timeout back to whatever layer 3's own default happened to be for that field, not layer 2's value. That restate-everything requirement is the direct, practical consequence of the full-replace rule described above, and it's the detail most likely to bite you the first time you write a machine-level override.

Debugging with --dump-config and --dump-default-config

Because four layers stack silently, the fastest way to find out what a running profile actually resolves to is to ask dsh to print it instead of guessing:

# just the bundle layer — what the profile ships with, before your overrides
dsh --profile web --dump-default-config

# the full combined tree — bundles + profile patch + machine patch + --patch flags
dsh --profile web --dump-config

Both flags print the composed config tree and exit without starting the app — no session, no port bound. --dump-default-config is useful for seeing what a fresh profile looks like before you touch it; --dump-config is what you diff against when something isn't behaving the way you configured it. Note that launcher flags like these have to appear before the first token the launcher doesn't recognize — that boundary is where "launcher flags" end and "app-specific arguments" begin (see DeepSeek Harness CLI Cheat Sheet for the full flag reference).

Applying an ad hoc override with --patch

--patch <path> layers one more YAML file on top of everything else, scoped to that single invocation — it doesn't persist anywhere:

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

This is also how local plugin development typically loads a not-yet-installed plugin: insert an entry whose name is an absolute path to your plugin file, then run with --patch pointing at that file. Multiple --patch flags apply in the order given on the command line.

The .env layer and credential resolution

Configuration and credentials are resolved separately. Credential lookup for model providers walks: inherited process environment → $DSH_HOME/.credentials.yaml → a .env file in the directory you invoked dsh from → $DSH_HOME/.env. Managed credential documents are never written into process.env; the two .env files are ordinary startup environment layers, not part of the cordis.patch.yml chain. See DeepSeek Harness CLI Cheat Sheet for the environment variable table (DSH_HOME, DSH_PERMISSION_MODE, DSH_TOOLS_MODE, telemetry variables, and more).

FAQ

Does --patch persist across runs, or do I need to pass it every time?

It's per-invocation only. If you want a permanent change, put it in the profile's own cordis.patch.yml (persists for that profile) or $DSH_HOME/cordis.patch.yml (persists across all profiles on the machine). --patch is for one-off overrides and local plugin development.

If two bundles insert rows with the same id, what happens?

Rows in later-loaded bundles (per dsh.profile.bundles list order) override earlier ones with the same id, following the same full-replace rule as any other layer — there's no automatic merge between two bundles targeting the same row.

Can I see the config without starting dsh?

Yes — --dump-config (full combined tree) and --dump-default-config (bundle layer only) both print and exit, no session or port involved.

Why did my one-field override wipe out other settings on the same row?

Because patch overrides replace a row's entire config object rather than deep-merging. Restate the full config for that row, not just the field you're changing.

Next steps

Start from Profiles and Bundles in DeepSeek Harness, Explained if you haven't already, then use the DeepSeek Harness CLI Cheat Sheet for the complete flag and environment-variable reference. For a health check on whether a plugin's manifest and patch format are actually well-formed before you debug your own overrides, dsh-plugin-check in Development & Runtime runs a zero-dependency, read-only check against exactly this kind of issue.