Plugin Anatomy
One-liner: a DSH plugin = a cordis plugin (
apply(ctx, config)or a class) + adsh-field manifest in package.json: it declares capabilities (inject), what it contributes, and which layer it mounts on. Almost every DSH capability is stacked out of plugins.
Read this page and you'll understand "what a plugin really is, what forms exist, how the patch layer works, and how dependencies resolve". For hands-on writing see the dev series.
1. What a plugin means in DSH
DSH's philosophy is "everything is a plugin": tools, services, event listeners, UI panels are all plugins. A plugin is essentially two things:
① a cordis plugin (function apply(ctx, config) or class extends Service)
② a manifest declaration (the dsh field in package.json) — tells the loader how it mounts and what it depends on
The framework (@deepseek-ai/cordis, a vendored composition framework) handles: dependency injection, plugin loading, lifecycle, and scoping.
2. Plugin forms
| Form | Declaration | Role |
|---|---|---|
| Bundle plugin | dsh.bundle.patch | carries its own patch layer, joined into the profile as one layer |
| Client / dual-face | dsh.client + exports["./client"] | has both a node half and a browser half |
| Profile dependency | dsh.profile.bundles | declares which bundles a profile depends on |
There's no
dsh.plugin.jsonfile: the only real manifest is thedshfield in package.json. The repository plugins'.dsh-plugindirectory format belongs to the external plugin-registry mechanism, not DSH core; see Plugins.
Client / dual-face plugins
One package can provide both a host half and a client half (browser):
- host half (node): scans the host Loader entries to compose the
window.__DSH_BOOT__boot map and serves/plugins/<id>/client.js - client half (browser): a lazy CJS module table: side effects run only on first require
- declaration:
exports["./client"]+dsh.client
See Web UI Architecture for details.
3. Minimal plugin
import { Context } from '@deepseek-ai/cordis'
export const name = 'my-plugin'
export const inject = ['tools'] // declare dependencies: undeclared access is denied
export function apply(ctx: Context, config: {greeting: string}) {
const dispose = ctx.tools.register({
name: 'say_hello',
parameters: { text: { type: 'string' } },
output: {
schema: { type: 'string' },
render(_args, value) {
return [{ type: 'text', text: typeof value === 'string' ? value : 'ok' }]
},
},
async execute({ text }) {
return { content: `${config.greeting} ${text}` }
},
})
// ctx.plugin(...) / return cleanup ...
}
4. Two plugin forms: function vs class
| Form | Style | Dependency declaration | Typical |
|---|---|---|---|
| function | export function apply(ctx, config) | export const inject = [...] | mount tools / listen to events, stateless |
| class | class extends Service | static inject + @Inject | provide a service capability (see Writing a Service) |
- a class plugin's dependencies are read from
static injectand normalized throughInject.resolve, a different source fromapply'sinject - the
@Injectdecorator declares on class properties/methods; it can also addconfigtoinjecton functions
5. inject and capability safety
- plugins statically declare the services to inject at load time (array or object)
- access to undeclared services is denied by the context proxy (Proxy): capability-based
- dependencies are declared statically, so the loader can examine and approve them at load time
- injection is "declare first, then use"; dynamically-but-injected services can be fetched asynchronously with
ctx.inject(['x'], cb)
This relates to, but is not the same as, the security boundary:
injectgoverns "whether you can reach this service at all"; permissions/sandbox govern "whether what you do is allowed". See Sandbox & Security.
6. Patch-layer semantics (getting these right matters)
cordis.patch.yml is a top-level YAML array; each entry has only two operations:
insert(indented sub-list): append 1+ rows to a target group, keyed by id- override an entire row by id: without
insert,idlocates an existing row and can changename/config/disabled/inject/group/isolate/intercept
Three constraints that are easy to trip on:
| Constraint | Explanation |
|---|---|
config is a whole-row replacement, not a deep merge | the fields the patch gives are exactly what the plugin receives (missing ones use schema defaults) |
name mismatch → silently skipped | if the patch has a name that doesn't match the target row, the loader only warns and the entry becomes ineffective |
there's no replace/ignore verb | rows inserted by insert can later be configured/disabled by id via a later patch |
Complete application order
profile.bundles (bundle patches in order)
→ win32 shell layer (Windows only)
→ the profile's own cordis.patch.yml
→ $DSH_HOME/cordis.patch.yml (machine-level, overrides every profile)
→ --patch overlays
→ agent-presets roots
→ telemetry toggle
The root cordis.yml is rewritten to empty [] on every startup: the real config tree is 100% composed from the patch layers.
7. Mounting and reconcile: dsh plugin
dsh plugin --profile <name> <args> is a very thin pnpm forwarder:
first use → initProfile (directory + package.json + empty patch + pnpm-workspace.yaml)
→ run pnpm <args> in the profile directory
→ reconcile the dsh.profile.bundles layer list against the installed state
(dependencies resolving to packages that declare dsh.bundle → join the layer stack; dependencies removed / without a bundle → leave)
It reconciles by installed state, not by dependency diff: so an update can activate a package that "only gained a dsh.bundle declaration in the new version".
8. Dependency resolution (rescope)
- the framework is vendored and published only as
@deepseek-ai/*scoped packages - plugins should
import ... from '@deepseek-ai/cordis',z from '@deepseek-ai/schemastery' - the new loader's load closure contains only
@deepseek-ai/*packages; using bare namescordis/schemasteryfails to resolve (official issue #554) - workaround: install the missing bare-name dependency in the plugin's own directory
- the profile directory:
bundle name dual-anchor resolution(install directory first, then profile) + the$DSH_HOME/profiles/node_modulesflat-closure fallback, so out-of-tree plugins resolve to the same cordis instance
9. Verification
# inspect plugin mounting and layer-source comments in the composition tree
dsh web --dump-config | grep -B1 -A2 "my-plugin"
# inspect a profile's bundle layer list
node -e "console.log(require('./profiles/web/package.json').dsh?.profile?.bundles)"
Next steps
- Write your first plugin: get hands-on
- Event System: how plugins communicate
- Agent Presets: compose a set of plugins for an agent with a preset