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:
| Package | Role | Key |
|---|---|---|
agent-presets | Preset vocabulary, filesystem discovery, the guarded per-agent mount | ctx.agentPresets |
persona | The 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."
| Directory | preset.yml name | Order |
|---|---|---|
standard | 标准模式 | 1 |
code | PTC 模式 | 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: trueThe 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: 300000Note 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/band 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.