Skip to main content
PathDocs

Plugin Anatomy

One-liner: a DSH plugin = a cordis plugin (apply(ctx, config) or a class) + a dsh-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

FormDeclarationRole
Bundle plugindsh.bundle.patchcarries its own patch layer, joined into the profile as one layer
Client / dual-facedsh.client + exports["./client"]has both a node half and a browser half
Profile dependencydsh.profile.bundlesdeclares which bundles a profile depends on

There's no dsh.plugin.json file: the only real manifest is the dsh field in package.json. The repository plugins' .dsh-plugin directory 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

FormStyleDependency declarationTypical
functionexport function apply(ctx, config)export const inject = [...]mount tools / listen to events, stateless
classclass extends Servicestatic inject + @Injectprovide a service capability (see Writing a Service)
  • a class plugin's dependencies are read from static inject and normalized through Inject.resolve, a different source from apply's inject
  • the @Inject decorator declares on class properties/methods; it can also add config to inject on 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: inject governs "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, id locates an existing row and can change name/config/disabled/inject/group/isolate/intercept

Three constraints that are easy to trip on:

ConstraintExplanation
config is a whole-row replacement, not a deep mergethe fields the patch gives are exactly what the plugin receives (missing ones use schema defaults)
name mismatch → silently skippedif 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 verbrows 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 names cordis/schemastery fails 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_modules flat-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