English | 中文
How a plugin puts its own configuration on the web settings page. Nothing in this path needs a change inside this repository: the Host serves every registered settings namespace, and the Plugins section keys its cards on the namespace they edit, so a plugin that registers both halves is paired up automatically.
The two halves live in one package — the Host half under src/, the browser half under src/client/, exported as ./client and declared with dsh.client. packages/client/ui-theme is a worked example of that packaging; the cards this section ships live in packages/client/ui-settings-plugins.
The namespace is the join key, so pick it once and spell it in both halves. A consumer that already has a cordis.yml entry should register through installSettingsSection, which layers the entry under the user document and keeps working when no settings provider is mounted:
import type { Context } from '@deepseek-ai/cordis'
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import z from '@deepseek-ai/schemastery'
declare function assertReachable(endpoint: string | undefined): void
declare function rebuildFromSettings(config: Config): void
export const MY_PLUGIN_NS = settingsNamespace('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
installSettingsSection(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()) },
})
}role('secret') on a field keeps its value off every response; the card writes such a field into an update/mutate payload, or addresses a credential reference through the credentials domain instead. applies: 'restart' tells a configuration surface the owner acts on a change only at the next start.
The card registers into settings.plugin.item under its namespace and owns everything inside it — chrome, controls, and copy. It reads and writes through ctx.settingsScope, which fences each write with the revision it read:
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// 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' }))
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
name: 'settings.plugin.item',
key: 'my-plugin',
locale: 'settings.myPlugin',
inject: () => card.inject(),
}, MyPluginCard),
)
}The scope snapshot carries what a form needs: the resolved value, the composition base, and the raw user layer, whose key presence — not its value — is what marks a field overridden. scope.set(field, value) stores one field and scope.unset(field) clears it back to the composition layer.
The Plugin configuration tab reads which namespaces the Host serves and dispatches one slot key per namespace. A card is rendered when the Host serves its key and skipped when it does not, so a deployment that never composed the Host half shows no trace of the card. A served namespace no card claims renders nothing — that is how the namespaces owned by other pages (ui-theme, permission, llm-*) stay off this tab.
Cards appear in the order they registered into the slot; a keyed entry declares no order of its own.
The browser half is served to the page by the client module system, which scans the enabled Loader entries for packages declaring dsh.client and serves each one's built ./client export. So the plugin appears on the page as soon as a cordis.yml mounts it — no rebuild of the web application.
The bundle must be the loader's lazy-CJS factory artifact. Inside this repository tsdown.config.ts is three lines over the shared preset:
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-my-plugin', ['lib/types/index.js', 'lib/types/invariant.js'])That preset is not published today, so a package outside this repository has to reproduce the same output format itself. The bundle-purity gate also rejects value imports across plugins, so a card cannot import this section's card chrome or its staged-form model — it renders its own, and owns its own staging and revision fencing. Both limits are recorded under the section's known limitations.
{ "exports": { ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } }, "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings-plugins"] } } }