dshkit

DeepSeek Harness agent presets: the four modes are directories

A preset is a directory holding one agent.cordis.yml. The shipped roster, why authoring is copy-only, the produced-nothing switching rule, how a preset's rows resolve, and why a broken preset is listed rather than skipped.

Updated 2026-08-146 min
Short answer

An agent preset is a directory holding one agent.cordis.yml, mounted per agent scope so one process can run several differently composed agents at once. Four ship with the CLI — standard, code, minimal and cordis — which are exactly the four agent modes. Authoring is copy-only: you copy an existing preset's whole directory into a user root and edit the copy.

The "four agent modes" everyone writes about are four directories. Once you see that, the whole customisation story stops being mysterious — including how to build a fifth.

What a preset is

An agent preset is a directory holding one agent.cordis.yml. Mounting it under an agent's scope context gives that session its own tools and prompt sections while every other live session keeps its own, so one process can run several differently composed agents at once.

Two packages:

PackageRoleKey
agent-presetsPreset vocabulary, filesystem discovery, the guarded per-agent mountctx.agentPresets
personaThe agent persona as a composable row, so a preset can change identity and not only tools

The shipped roster

They live in apps/cli/config/agent-presets/, one directory each — and that directory listing is the roster. The repository deliberately does not name them in prose, because "naming them here too would be a second list to keep in step, and the first one to fall behind."

Directorypreset.yml nameOrder
standard标准模式1
codePTC 模式2
minimal极简模式3
cordis创造模式4

What a preset may and may not contain

The composition split: registries and cross-session facilities are process singletons and stay in the host composition, while a preset carries what one agent contributes to them.

A preset that names a row publishing a process-global service is rejected at mount rather than allowed to collide with the next session. If you need a per-session service, put it behind an entry-local realm with isolate::

- id: svc
  name: ./plugins/global-service.js
  isolate:
    fixtureIsolatedSvc: true

The same provider behind an entry-local realm never reaches the root realm, so it is per-session rather than process-global — and therefore accepted.

Here is the real minimal preset, which is the clearest illustration of what a composition file looks like:

- id: persona
  name: '@deepseek-ai/dsh-persona'
  config:
    text: You are a helpful software engineer assistant.
    complete: true
    includeRuntimeContext: false
 
- id: persistent-shell
  name: cordis:group
  group: true
  isolate:
    terminals: true
  config:
    - id: pty
      name: '@deepseek-ai/dsh-terminal'
    - id: terminal-bash
      name: '@deepseek-ai/dsh-terminal-bash'
      config:
        timeoutMs: 300000
    - id: persistent-bash
      name: '@deepseek-ai/dsh-tool-bash-persistent'
      config:
        timeoutMs: 300000

Note complete: true on the persona — the persona is the entire system prompt, so global identity, tool guidance and later assembly listeners cannot add prompt text. And includeRuntimeContext: false suppresses runtime context snapshots. That is how "minimal" is achieved: not by disabling features, but by composing fewer rows.

Authoring is copy-only

There is exactly one authoring write: copy(from, id, name?). You copy an existing preset's whole directory — composition, metadata, skill directories, assets — into the first user-trust root.

The reasoning is worth borrowing: no composition text crosses the service boundary, so a copy is exactly as loadable as its source, and a copy grants nothing the roster did not already carry. Everything after creation happens in the preset's own files.

copy() refuses three things up front:

  • An id that is not [a-z0-9][a-z0-9-]*. The id becomes a directory name, so containment is a property of the id itself rather than a path check after the fact — ../escape, a/b and absolute paths are all rejected as ids.
  • An id already taken. Any root supplying it refuses; a directory occupying the name on disk refuses too.
  • An unknown source. A failed copy rolls its half-made directory back.

The copied tree is re-tightened to owner-only (0o600 files, 0o700 directories) and symlinks are dereferenced so the copy is self-contained. The copied preset.yml keeps the source's description but drops its name and roster order — a copy presenting itself identically to its source would make the roster stop distinguishing them.

remove() refuses a preset that ships with the deployment.

Switching, and why it is locked

A session's creation header names the preset it started with; resolveSessionPreset(session) names the one it runs. Those differ whenever a blank session switched, so every reconstruction path — a picker summary, a resume, a fork — resolves rather than reading the header.

A switch is an agent-preset/selected session event appended after the swap commits. That is the model-visible ⟺ logged rule: the preset decides the tool schemas and prompt sections the model sees, so it must be reconstructable from the log.

recompose() unmounts the installed subtree and mounts the new one, because two compositions cannot coexist — both would register the same tool names into one layer. A failed mount restores the previous composition rather than leaving the agent with nothing.

How a preset's rows resolve

This is the detail that will bite you when you author one.

Package names resolve from the host composition, not the preset directory. The Loader normally resolves an entry against its own tree's baseUrl, which for a preset is wherever the composition file sits. A locally authored preset lives under the user's home, where Node's upward node_modules walk never reaches the harness — so every @deepseek-ai/dsh-* row would fail to import. The mount records the host base before plugging the subtree and sends bare specifiers there.

Relative paths resolve from the preset's own directory, so a preset's own plugin files and skill directories travel with it.

Absolute paths keep their own location, converted to a file: URL before ESM import so POSIX and Windows drive-letter or UNC paths both work.

Sub-agents join, they do not re-mount

A sub-agent's child joins its parent's standing composition through composeFrom(), never through mount(). Two reasons, both real:

  • A composition file edited since the parent started would hand the child a different generation than the one its parent's history was produced under.
  • A preset deleted since would fail the child outright while its parent keeps running.

The bind is also synchronous, which is what lets the in-process sub-agent drivers use it at all — they compose children inside a synchronous creation window.

And the consequence worth knowing: every model-facing row lives on the agent plane, so the tool registry's global layer is empty. A child that joins nothing reaches the model with no tools at all and none of its parent's prompt sections.

Discovery, health and staleness

Discovery is unmemoized: list() and resolve() re-read the roots on every call, so a preset authored while the process runs is visible immediately.

A directory whose composition is missing or unloadable is listed with a broken reason rather than skipped — because a skipped directory would still occupy its id on disk while every surface showed nothing to delete. A directory whose name is not a usable preset id is skipped outright, since no copy could ever claim it.

Each mount generation records its composition file's stamp (mtime and size). A session that finds the stamp stale starts the next generation, while every session already joined keeps the one it runs on — so the composition a running session joined outlives its file changing or disappearing underneath it.

Frequently asked

Where do the four agent modes actually live?

In apps/cli/config/agent-presets, as the directories standard, code, minimal and cordis. Each preset.yml carries the display name — code is 'PTC 模式' and cordis is '创造模式' — so the modes people discuss are these preset directories.

Why can I not write a preset from scratch through the API?

Authoring is copy-only by design: no composition text crosses the service boundary, so a copy is exactly as loadable as its source and a copy grants nothing the roster did not already carry.

Why can I not switch presets mid-conversation?

Swapping tools mid-conversation would leave logged tool calls the new composition cannot make. The restriction is a product rule enforced at the gateway, which answers agent-preset-locked.

Why does a broken preset still show up in the list?

A skipped directory would still occupy its id on disk while every surface showed nothing to delete. So a preset whose composition is missing or unloadable is listed with a broken reason instead.

Keep reading