DeepSeek Harness plugin UI: settings cards and slots

Plugin DevelopmentPublished 2026-09-12Author: DeepSeek Plugin Market
DSH pluginDeepSeek Harnessplugin UIsettings cardslots
How to build a DeepSeek Harness plugin UI: a Host half that registers a settings namespace and a browser half that registers a card, plus client packaging.

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:

ts
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:

ts
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).

LayerMeaningUse
valueThe resolved effective valueRender the form
baseThe composition layerShow where a default comes from
userThe raw user layerKey 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):

  1. Expose the browser half through exports['./client'] and declare dsh.client (with platform and the client packages it injects);
  2. 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;
  3. 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:

  1. Do both halves import the same namespace constant?
  2. Does the Host half handle validate (constraints the schema cannot express), setSource, and onChange?
  3. Are sensitive fields marked role('secret'), and is applies: 'restart' set where a change lands at the next start?
  4. Does the card use type-only imports across plugins?
  5. Are exports['./client'] and dsh.client in 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

Why does DeepSeek Harness plugin UI development need two halves?

**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").

How does the Host half of a DSH plugin register a settings namespace?

**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").

How does the browser half attach a card to the settings page?

**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").

What are value, base, and user in a ctx.settingsScope snapshot?

**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").

What packaging rules apply to a DSH plugin's browser half?

**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

Sources