DeepSeek Harness plugin UI: settings cards and slots
DeepSeek Harness plugin UI development is not "write a frontend page": it means writing two halves in one package — a Host half that registers a settings namespace with ctx.settings.installSection(), and a browser half that registers a card into the settings.plugin.item slot. The namespace pairs them automatically, with no change to the host repository. The same two-half structure applies to any DSH plugin.
DSH plugin UI: the two halves, one package with two entries
The settings page is built from a Host half and a browser half, and a missing half means no card. The official Cookbook puts it plainly: the Host serves every registered settings namespace and the Plugins section keys its cards on the namespace they edit, so the two halves are paired up automatically. Both live in one package — the Host half under src/, the browser half under src/client/, exported as ./client and declared with dsh.client (source).
my-plugin/
├── src/index.ts # Host half: register the namespace
├── src/client/index.tsx # Browser half: register the card
└── package.json # exports['./client'] + dsh.client
There is exactly one pairing key: the namespace. Make it a constant (such as MY_PLUGIN_NS = 'my-plugin') that both halves import, so a typo cannot silently make the card disappear.
DSH plugin Host half: register a settings namespace
A plugin that already has a cordis.yml entry should use ctx.settings.installSection(), which layers the entry under the user document and keeps working when no settings provider is mounted. The official Host half:
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-settings'
import z from '@deepseek-ai/schemastery'
export const MY_PLUGIN_NS = 'my-plugin'
export interface Config {
endpoint?: string
retries?: number
}
export const Config: z<Config> = z.object({
endpoint: z.string(),
retries: z.number().step(1).min(0).default(3),
})
export function apply(ctx: Context, config: Config) {
let source = () => config
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.installSection(ctx, MY_PLUGIN_NS, Config, config, {
// Constraints the schema cannot express refuse the write, not the next use.
validate: value => void assertReachable(value.endpoint),
setSource: (current) => { source = current },
onChange: () => { rebuildFromSettings(source()) },
})
})
}
Two easy-to-miss declarations: role('secret') on a field keeps its value off every response (the card then writes it into an update/mutate payload or addresses a credential reference through the credentials domain); and applies: 'restart' tells a configuration surface that the owner only acts on the change at the next start.
DSH plugin browser half: register the card into the slot
Cards register into the settings.plugin.item slot, and key must match the Host half's namespace. The official example:
import type { Context as ClientContext } from '@deepseek-ai/cordis'
// Type-only: the keyed slot's declaration. Cross-plugin collaboration goes
// through cordis services; a value import fails the client bundle-purity gate.
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
export function apply(ctx: ClientContext): void {
const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: MY_PLUGIN_NS }))
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
name: 'settings.plugin.item',
key: MY_PLUGIN_NS,
locale: 'settings.myPlugin',
inject: () => card.inject(),
}, MyPluginCard))
}
The card owns everything inside it: chrome, controls, and copy. Note that import type {} line — across plugins only type imports are allowed, because a value import fails the bundle-purity gate.
DSH plugin config read and write semantics: value, base, user
The scope snapshot carries the three layers a form needs, and "is it overridden" is decided by key presence rather than value (source).
| Layer | Meaning | Use |
|---|---|---|
value | The resolved effective value | Render the form |
base | The composition layer | Show where a default comes from |
user | The raw user layer | Key present = the field is user-overridden |
Write with scope.set(field, value) to store a single field, and scope.unset(field) to clear it back to the composition layer. Every write is fenced with the revision it read, which prevents two settings surfaces from overwriting each other.
DSH plugin UI display rules and packaging
The display rule is direct: a card renders only when the Host serves its key and the card claims it; if the Host does not serve the key, the card does not appear at all; and a served namespace no card claims renders nothing — which is exactly why ui-theme, permission, and llm-* stay off that tab. Card order equals registration order, and a keyed entry cannot declare its own order.
Three packaging requirements (source):
- Expose the browser half through
exports['./client']and declaredsh.client(withplatformand the client packages it injects); - the bundle must be the loader's expected lazy-CJS factory artifact — no published preset exists outside the repo, so you reproduce the output format yourself;
- no cross-plugin value imports: the card brings its own chrome and staging model, and owns its own staging and revision fencing.
The moment cordis.yml mounts the plugin, it appears on the page without rebuilding the web app, because the client module system scans enabled entries and serves each built artifact according to dsh.client.
DSH plugin UI checklist and next steps
Run through this before shipping:
- Do both halves import the same namespace constant?
- Does the Host half handle
validate(constraints the schema cannot express),setSource, andonChange? - Are sensitive fields marked
role('secret'), and isapplies: 'restart'set where a change lands at the next start? - Does the card use type-only imports across plugins?
- Are
exports['./client']anddsh.clientin place, with a lazy-CJS factory bundle?
Tool-call cards are a separate track (presentCall / presentResult, which must be pure functions) — see how to write a tool plugin. For configuration outside the UI, see plugin configuration; for distribution, see packaging into a bundle and publishing to the plugin hub.
FAQ
**A DSH plugin settings page is built from a Host half and a browser half**: the Host half registers a namespace and decides which config keys are served, while the browser half registers a card and decides how it renders. **The namespace pairs them automatically** — the Plugins section keys cards on the namespace they edit — so you only write src/ and src/client/ in one package and never touch the host repo (source: official Cookbook "Adding a settings card").
**A DSH plugin's Host half registers its namespace with ctx.settings.installSection()**, passing the plugin context, namespace constant, Config schema, current config, and { validate, setSource, onChange }. The deciding factor is whether the plugin already has a cordis.yml entry — a consumer that does should use installSection, which layers the entry under the user document and keeps working when no settings provider is mounted (source: official Cookbook "Adding a settings card").
**A DSH plugin's browser half registers the card into the settings.plugin.item slot**: ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name, key, locale, inject }, Card)), where key must be the same namespace the Host half registered. Inside the card, ctx.settingsScope.bind({ namespace }) provides the scope used to read and write config (source: official Cookbook "Adding a settings card").
**A DSH plugin settings card's scope snapshot carries the three layers a form needs**: value is the resolved effective value, base is the composition layer, and user is the raw user layer. Whether a field is overridden is decided by the **presence** of that key in user, not its value; scope.set(field, value) stores one field and scope.unset(field) clears it back to the composition layer (source: official Cookbook "Adding a settings card").
**Publishing a DSH plugin's browser half has three hard requirements**: ① expose the browser half through exports['./client'] and declare dsh.client in package.json; ② the bundle must be the loader's expected lazy-CJS factory artifact; ③ no cross-plugin value imports — the client bundle-purity gate rejects them, so a card brings its own chrome and staging model. In-repo you use the shared clientBundle() preset; outside the repo you reproduce the same output format yourself (source: official Cookbook "Adding a settings card").
Related Terms
- settings.installSection()
- installSection is how a DSH plugin's Host half registers a settings namespace: it takes the context, namespace, Config schema, current config, and validate / setSource / onChange callbacks so the plugin's config appears in the Plugins section.— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-settings-card.md
- settings.plugin.item slot
- settings.plugin.item is the slot for the Plugins section of the settings page. A DSH plugin's browser half registers its card there, and the slot pairs with the Host half by key (the namespace); a key the Host does not serve renders no card.— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-settings-card.md
- ctx.settingsScope
- settingsScope is the DSH plugin browser-half scope for reading and writing config; every write is fenced with the revision it read. Its snapshot exposes value / base / user, with set for one field and unset to clear it back to the composition layer.— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-settings-card.md
- dsh.client
- The dsh.client field in package.json declares that a DSH plugin ships a browser half. The client module system scans enabled entries for it and serves each built ./client export, so mounting the plugin in cordis.yml puts it on the page without rebuilding the web app.— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-settings-card.md