# DSH plugin platform reference (Web UI plugins, host route, credentials) Verified against the **running** build: dsh `0.2.1-rc.2` (commit `332895e0f7c9d4dbfb29729f5eb367fa263ff986`), Node `24.21.0`, profile `desktop` at `~/.dsh/profiles/desktop`, Web GUI `http://127.0.0.1:57386`. Everything below was read out of the installed plugins and out of the platform SDK inside the running app; nothing is guessed. ## 0. Where the evidence lives | Source | Path | Notes | |---|---|---| | Installed reference plugins | `~/.dsh/profiles/desktop/node_modules/{dsh-ai4scholar,@ggboy123/dsh-paper-reader,dsh-skill-starter,dsh-mimir}` | real third-party plugins | | Profile composition | `~/.dsh/profiles/desktop/{package.json,cordis.patch.yml,cordis.yml,pnpm-workspace.yaml}` | profile manifest + user patch layer | | Platform SDK (authoritative) | `/tmp/dsh-asar/dsh/node_modules/@deepseek-ai/*` | read-only extraction of `app.asar`; 307 packages. Paths in text below are package-relative, e.g. `dsh-client-ui-renderer/lib/client.js`. Re-extract with `npx @electron/asar` if `/tmp` is wiped — inside the archive shell tools cannot read it. | | Official plugin-authoring skill | `@deepseek-ai/dsh-agent-preset/skills/cordis-plugin-development/` (`SKILL.md`, `references/{host-plugin,ui-plugin,practices,verification,user-actions}.md`, `templates/decoration/*`) | shipped with the app; the templates are the canonical minimal example | | Loader patch dialect | `@deepseek-ai/dsh-agent-preset/skills/cordis-composition-reference/SKILL.md` | `insert` / override-by-id / `disabled` / `!!js` / `isolate` | | Live inspectors | `cordis_inspect_query` (host `Service`, host `Config`, client `Slots`/`Theme`) | host queries work in this session; **client queries timed out**: no page is attached to the client-inspect channel right now. Client facts below come from the same data the inspector serves (`dsh-cordis-client-runner/lib/client.js` `SLOT_CATALOG`, ~line 2400+), which is generated from the shipped slot contracts. | Two installed plugins are **hand-written, never bundled** — this settles the "no bundler required" question: `@ggboy123/dsh-paper-reader/lib/client.js` (1703 lines, `window.__ModuleLoader__.load({...})`, header comment: "浏览器源码,直接分发,不经任何构建步骤重写") and `dsh-skill-starter/client.js` ("浏览器源码,直接分发,不经任何构建步骤重写"). The official template `templates/decoration/client.js` is equally plain JS. --- ## 1. Host half: package.json, patch, module contract, effects ### 1.1 package.json Copy `templates/decoration/package.json` and add the plugin's own fields. Minimal, working shape: ```json { "name": "dsh-scnet-plan", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./index.js", "./client": "./client.js", "./locale/*.json": "./locale/*.json", "./icon": "./icon.svg", "./package.json": "./package.json" }, "files": ["index.js", "client.js", "cordis.patch.yml", "locale/*.json", "icon.svg"], "engines": { "node": ">=22.19.0" }, "dsh": { "bundle": { "patch": "./cordis.patch.yml" }, "client": { "platform": "web", "immediately": true, "inject": ["@deepseek-ai/dsh-client-ui-sidebar", "@deepseek-ai/dsh-client-ui-settings"] } } } ``` Field semantics, all enforced in code: - `dsh.bundle.patch` — one path or an ordered list of paths; read by `bundlePatchFiles()` (`@deepseek-ai/dsh-app-boot/lib/index.js:517`); non-string entries throw `dsh.bundle.patch must be a file path or a list of file paths`. A package listed in `dsh.profile.bundles` **without** `dsh.bundle` throws and is skipped (`loadProfileDirectory`, same file ~1030). - `dsh.client.platform` — must be `"web"`, otherwise the package contributes no browser bundle (`dsh-client-modules/lib/index.js:733`: `if (decl === void 0 || decl.platform !== "web") … return null`). Every shipped package uses `"web"`. - `dsh.client` also accepts `immediately` (boolean) and `external` (string[]). `inject`/`external` are validated as string arrays (`parseDshClient`, `dsh-client-modules/lib/client.js:60-77`). - `inject` = **package ids** whose client bundles must arrive (register factories) before yours; it does *not* inject services. Unknown package ids are ignored at load. - `external` = package ids that must be *loaded and materialized* before yours; it also orders the graph (`orderByModuleGraph`) and is what you should declare when you `require()` another plugin's exports. - **`immediately: true` is only a prefetch hint** (`prefetchImmediateTier()`): every graph row is still in the initial "application" batch and mounted at boot (`compose()`, `dsh-client-modules/lib/index.js:659-668`). - `exports["./client"]` — required whenever `dsh.client` is declared; must be a string or an object with a string `default` (`clientExportOf`, `dsh-client-modules/lib/index.js:172-184`). Missing it throws `declares dsh.client but exports no "./client" bundle`. - `exports["."]`, `main` — the host entry. The loader imports the row's `name` specifier directly (`Loader.import`, `cordis-plugin-loader/lib/index.js:214`), so `exports["."]` alone is enough for a bare package name. - `dsh.client.inject` values are **not** dependencies: the profile installs no peers (`pnpm-workspace.yaml` sets `autoInstallPeers: false`). They only have to exist in the boot graph. Unknown names are silently skipped (`arriveGraphRow` only follows ids present in `graphRows`), so a stale list is harmless but useless — the real plugin here lists `@deepseek-ai/dsh-client-runtime`, which is **not a package in 0.2.1-rc.2**; `@deepseek-ai/dsh-client-locale` and `@deepseek-ai/dsh-client-modules` are, while `@deepseek-ai/dsh-client-ui-slots` lives in the seed table rather than as a graph row. Service readiness never comes from this list — it comes from `exports.inject`. - `name` is load-bearing three times: the loader row's `name` resolves to it, the browser bundle's `id` must equal it, and the `__ModuleLoader__` factory must register under it. Paper-reader's comment says it outright: "必须等于 npm 包名:浏览器端 loader 按包名期待注册(改名时漏改这里 → "loaded without registering" 整页插件加载失败,实测)". ### 1.2 cordis.patch.yml insert shape A bundle patch is a **top-level YAML array of patch mappings**. An insert row is: ```yaml # scnet-plan/cordis.patch.yml - insert: - id: scnet-plan # patch id = settings namespace (see §4) — must be unique tree-wide name: 'dsh-scnet-plan' # PACKAGE NAME (not a path) — resolved through node_modules config: # optional; replaces the plugin's Config defaults wholesale apiBase: https://api.scnet.cn apiKeyEnv: SCNET_API_KEY ``` Rules (from `cordis-composition-reference/SKILL.md`, confirmed in `dsh-app-boot`): - `insert: [rows]` appends rows; **row `name` is a package specifier**. A *relative* name is anchored beside the patch file, so `name: './index.js'` works for a self-contained dev bundle. - A patch with `id` and no `insert` **overrides** the existing row with that id. Fields replace the row's fields, and `config` is replaced **wholesale** — never deep-merged — so restate every key you keep. `disabled: false` is how a user re-enables a row a bundle ships disabled. - Row fields: `id`, `name`, `config`, `disabled`, `inject`, `intercept`, `isolate`, `group`. - `group: true` + `name: cordis:group` makes `config` a nested entry list; an insert with `id` naming a group appends inside it. - `!!js` scalars are loader expressions, evaluated against the plugin's context after its declared injections activate (e.g. `!!js "!ctx.get('profileContext')"`). - Non-insert patches without a non-empty `id`, and targets matching no row, are warned about and skipped. Composition order (`@deepseek-ai/dsh-app-boot/lib/index.js:485-503`): each bundle's patch list in `dsh.profile.bundles` order over an empty tree → the profile's own `cordis.patch.yml` → `--patch` overlays. ### 1.3 Module contract `dsh-plugin-development/references/host-plugin.md` allows exactly two shapes (do not mix): ```js // A) function plugin — what scnet-plan should use export const name = 'scnet-plan' // loader diagnostics / fiber name export const inject = ['tools'] // services required before apply(); optional export const Config = Schema.object({ ... }) // @deepseek-ai/schemastery; optional export function apply(ctx, config) { /* register everything here */ } // B) default-exported Service class (provides a cordis service) ``` - `name` becomes the fiber name and shows in loader diagnostics; the plugin still works without it, but export it. - `inject` is the **service** list (cordis keys such as `tools`, `webServer`, `commands`, `systemPrompt`, `settings`), *not* package names. Anything optional goes in `ctx.inject([...], cb)` instead, so the plugin stays inactive in profiles that lack the service. - `Config` is validated by the loader at activation; `config` passed to `apply` is the validated object with defaults applied. The same schema is what the Settings/Plugin-Manager UI renders — inspect it live with `cordis_inspect_query {platform:"host", provider:"Config", method:"listConfigs", input:{name:""}}`, then query the returned `entry` for the full JSON Schema. Confirmed working for `dsh-ai4scholar`: `{"entries":[{"id":"include:ai4scholar","patchId":"ai4scholar","name":"dsh-ai4scholar","status":"schema"}],"total":1}`. - ESM only for this plugin (`"type": "module"`). The loader normalizes default/CJS shapes (`unwrapExports`, `cordis-plugin-loader/lib/index.js:666`) but a plain ESM `apply` export is the path of least resistance. **Which bare specifiers are safe to import.** The profile's `node_modules` deliberately contains only what the profile installed. It has **no `@deepseek-ai/dsh-tools`** — yet `dsh-ai4scholar/lib/tools/credits.js:7` does `import { defineTool } from '@deepseek-ai/dsh-tools'` and works (its tools are callable in this very session). The mechanism is the runtime module-resolution interception: `PluginPackages` is mounted with the profile resolution (`dsh/lib/profile-boot-C14i-8IN.js:306`, `hostCtx.plugin(PluginPackages, { resolution })`), whose constructor installs the interception (`dsh-app-boot/lib/index.js:1846` `installRuntimeInterception`, called from the service at ~3501), and the router (`routeScoped`, `dsh-app-boot/lib/index.js:1613+`) routes a bare request from any importer under the profiles tree to the installation's package directory. Practical rule: - ✅ `node:*` builtins; `@deepseek-ai/schemastery` (also installed in the profile); any package in the installation's closure — including `@deepseek-ai/cordis`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-credentials`, `@deepseek-ai/dsh-host-webserver`, `@deepseek-ai/dsh-commands`, `@deepseek-ai/dsh-system-prompt`, `@deepseek-ai/dsh-settings`, `@deepseek-ai/dsh-storage-domain`. - ✅ packages you declare in your own `dependencies` (pnpm installs them into the profile; local installs win over the installation). - ❌ anything else. The route table only falls back for names present in the installation closure or the profile; an unknown bare specifier fails with Node's normal resolution error. `peerDependencies` alone install nothing (`autoInstallPeers: false`). - Version truth: for platform packages the **installation's** copy wins; a profile-installed copy of the same name takes precedence for the profile layer, but a replacement the running process cannot adopt is refused with `profile resolution: replacing "" requires a process restart` (`dsh-app-boot/lib/index.js:1550`). ### 1.4 Effect / disposal rules From `practices.md` and cordis itself (`@deepseek-ai/cordis/lib/index.js:1169` `effect(execute, label)`): - Register **every** resource inside `apply` as an effect: `ctx.effect(() => webCtx.webServer.register({...}), 'scnet-plan: snapshot route')`. The plugin fiber owns it; unloading/disabling the plugin, a profile patch change, or an HMR swap disposes it. - `ctx.effect` accepts a disposer, `null`, a thenable, a sync iterable, or an async iterable of disposers; iterable disposers run in reverse. - `ctx.on(event, fn)` is also fiber-scoped. Bare `addEventListener`, `setInterval`, and module-level side effects are **not** disposed — do not use them. - `ctx.inject([...services], cb)` starts a child fiber that activates only when those services exist and is disposed with the parent; use it for `webServer`, `commands`, and `settings`. - Returning a disposer from `apply` is not a thing in this loader contract — use `ctx.effect`. - Registrations outside your own fiber (e.g. on `agent.ctx`) need their disposer retained in your own effect as well. A minimal host half that satisfies all of the above: ```js // scnet-plan/index.js import Schema from '@deepseek-ai/schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'scnet-plan' export const inject = ['tools'] export const Config = Schema.object({ apiBase: Schema.string().default('https://api.scnet.cn').description('SCNet API origin.'), apiKeyEnv: Schema.string().default('SCNET_API_KEY').description('Credential reference holding the SCNet API key.'), cacheTtlMs: Schema.number().default(60_000).description('Snapshot cache lifetime in milliseconds.'), }) const ROUTE = '/scnet-plan/snapshot' const REFRESH_ROUTE = '/scnet-plan/refresh' export function apply(ctx, config) { const ref = credentialRef(config.apiKeyEnv) const apiKey = async () => { const credentials = ctx.get('credentials') if (credentials !== undefined) return (await credentials.resolve(ref))?.value const ambient = process.env[ref] return ambient !== undefined && ambient.length > 0 ? ambient : undefined } let cache = { at: 0, value: undefined } const snapshot = async (signal, force = false) => { if (!force && cache.value !== undefined && Date.now() - cache.at < config.cacheTtlMs) return cache.value const key = await apiKey() if (key === undefined) return { ok: false, code: 'MISSING_KEY', error: 'SCNet API key is not configured' } try { const res = await fetch(`${config.apiBase}/api/plan/snapshot`, { headers: { Authorization: `Bearer ${key}` }, signal }) if (!res.ok) return { ok: false, code: `HTTP_${res.status}`, error: `SCNet returned HTTP ${res.status}` } cache = { at: Date.now(), value: { ok: true, data: await res.json() } } return cache.value } catch (error) { return { ok: false, code: 'REQUEST_FAILED', error: error instanceof Error ? error.message : String(error) } } } ctx.inject(['webServer'], (webCtx) => { webCtx.effect(() => webCtx.webServer.register({ kind: 'exact', path: ROUTE, handler: async (req, res) => { if (req.method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET' }) res.end(JSON.stringify({ ok: false, code: 'METHOD_NOT_ALLOWED' })) return } const controller = new AbortController() req.once('close', () => controller.abort()) const body = await snapshot(controller.signal) res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }) res.end(JSON.stringify(body)) }, }), `webServer.register(${ROUTE})`) webCtx.effect(() => webCtx.webServer.register({ kind: 'exact', path: REFRESH_ROUTE, handler: async (req, res) => { // State-changing route: require a header a cross-site form cannot set, and check the method. if (req.method !== 'POST' || req.headers['x-scnet-plan-action'] !== '1') { res.writeHead(403, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: false, code: 'FORBIDDEN' })) return } const body = await snapshot(undefined, true) res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }) res.end(JSON.stringify(body)) }, }), `webServer.register(${REFRESH_ROUTE})`) }) ctx.tools.register(defineTool({ name: 'scnet_plan_snapshot', description: 'Read the current SCNet compute-plan snapshot for the configured SCNet API key.', parameters: {}, output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true } } }, render: (_args, value) => [{ type: 'text', text: value.summary }], }, isConcurrencySafe: () => true, async execute(_args) { const body = await snapshot(undefined) if (body.ok !== true) throw new Error(body.error ?? 'SCNet snapshot failed') return { summary: JSON.stringify(body.data).slice(0, 2000) } }, })) } ``` --- ## 2. HTTP route: `ctx.webServer.register` (verified, copy from ai4scholar) Service contract from the live host inspector (`Service.listService {service:"webServer"}`): ```ts register(route: WebRoute): () => void interface WebRoute { kind: 'exact' | 'prefix' path: string handler: (req: IncomingMessage, res: ServerResponse) => void | Promise } ``` - Returns the **disposer** that removes the route. A duplicate `(kind, path)` **throws** — "route patterns are a composition-level contract, so a collision is a misconfiguration". - Matching: exact over the whole table → longest prefix → the single fallback handler (the SPA dist server). While no fallback is registered the server answers 404. - Other members: `getOrigin()`, `registerUpgrade({path, handler})`, `registerFallback(handler)` (one owner; a second throws), `tapIndex(html => html)`, `applyIndexTaps`, `collectIndexInjections`, `renderIndex`. The real, working registration — `dsh-ai4scholar/lib/tools/credits.js:127-159` (comment included because it documents the remount trap): ```js export function applyCreditsTools(ctx, runtime, command, tool, route) { if (route) { // `webServer` exists only in the web composition; headless never activates this child. ctx.inject(['webServer'], (webCtx) => { // `register` returns the disposer that removes the route, and a duplicate // `(kind, path)` pair throws. Hand the disposer to the fiber: a hot remount // (dshmarket toggling the plugin, a settings change) tears the old fiber // down and builds a new one, and without this the dead fiber's route was // still registered when the new one tried — `webserver: duplicate exact // route "/ai4scholar/balance"`, which killed the remount. webCtx.effect(() => webCtx.webServer.register({ kind: 'exact', path: BALANCE_ROUTE, handler: async (req, res) => { if (req.method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json', 'Allow': 'GET' }); res.end(JSON.stringify({ ok: false, code: 'METHOD_NOT_ALLOWED', error: 'GET only' })); return; } const controller = new AbortController(); req.once('close', () => { controller.abort(); }); let body; try { body = await answerBalanceRoute(runtime, controller.signal); } catch (error) { body = { ok: false, code: 'REQUEST_FAILED', error: error instanceof Error ? error.message : String(error) }; } res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); }, }), `webServer.register(${BALANCE_ROUTE})`); }); } … ``` `BALANCE_ROUTE = '/ai4scholar/balance'` lives in one shared module `lib/shared/balance-route.js` used by both halves, so the string cannot drift. **How the browser reaches it** — same-origin relative `fetch`, from `dsh-ai4scholar/lib/client.js:14-38`: ```js const BALANCE_ROUTE = "/ai4scholar/balance"; const fetchBalanceRoute = async (signal) => { const res = await fetch(BALANCE_ROUTE, { method: "GET", credentials: "same-origin", cache: "no-store", ...signal !== void 0 ? { signal } : {} }); if (!res.ok) return { ok: false, code: `HTTP_${res.status}`, … }; return await res.json(); }; ``` **Auth considerations (important).** - `dsh-host-webserver` is "a `node:http` server that knows no harness concepts". It ships **no authentication, no session check, and no CORS headers** on plugin routes. The shipped composition binds loopback (`host: 127.0.0.1`), so a plugin route is reachable by any local process and by a cross-origin page's *simple* requests; because no `Access-Control-Allow-Origin` is emitted, a cross-origin page cannot *read* the response, but it can still cause side effects. - Therefore: return only non-secret data; never echo the API key or any credential value; make state-changing routes `POST` and require a custom header a cross-origin form cannot set (skill-starter uses `x-dsh-skill-starter-action: '1'`, `dsh-skill-starter/index.js:26` + `client.js:264-268`); check `req.method` and answer `405`/`403` with JSON. - The authenticated surface is the `/api` RPC bridge (`ctx.connection` / `typertGateway`, trust + Peer admission), **not** named webServer routes. `ctx.connection.fetch.register(...)` (skill-starter) is the alternative seam if you want to live under `/api`; it registers `{ path, methods, requestBody, fetch }` and the browser fetches a *document-relative* path (`new URL('api/dsh-skill-starter/manage', document.baseURI)`). For scnet-plan the task specifies `ctx.webServer.register` — keep the custom-header discipline. - `ctx.webServer.getOrigin()` returns e.g. `http://127.0.0.1:3080`; it throws before the listener binds. Not needed for same-origin fetches. --- ## 3. `ctx.credentials`: exact types, env fallback, browser safety Service contract (`Service.listService {service:"credentials"}`; types are the inspector's verbatim declarations): ```ts resolve(ref: CredentialRef): Promise // { value: string; source: string } describe(ref: CredentialRef): Promise // { configured: boolean; source?: string; writable: boolean } set(ref: CredentialRef, value: string): Promise // empty value rejected unset(ref: CredentialRef): Promise // record half: readRecord / describeRecord / listRecords / modifyRecord / deleteRecord keyed by CredentialKey "/" ``` - `CredentialRef` is a branded string validated by `credentialRef(value)` against `/^[A-Za-z_][A-Za-z0-9_]*$/` (`@deepseek-ai/dsh-credentials/lib/index.js:19-30`). A bad name throws `TypeError`. `isCredentialRefName(value)` is the non-throwing probe. - Resolution is **per operation, never cached across operations** — that read is the hot-rotation mechanism. - A stored value outranks the environment; an empty stored value counts as absent everywhere. - `describe()` never returns the value — it is the only thing safe to forward to the browser. The reference implementation, `dsh-ai4scholar/lib/runtime.js:68-89` (verbatim): ```js export function makeCredentialResolvers(ctx, apiKeyEnv) { const ref = credentialRef(apiKeyEnv); const apiKey = async () => { const credentials = ctx.get('credentials'); if (credentials !== undefined) { const hit = await credentials.resolve(ref); return hit?.value; } const ambient = process.env[ref]; return ambient !== undefined && ambient.length > 0 ? ambient : undefined; }; const requireApiKey = async () => { const key = await apiKey(); if (key === undefined) { throw new Error(`AI4Scholar API key is not configured. Open Settings → Plugins → AI4Scholar in the dsh web UI, ` + `or store it as ${ref} in $DSH_HOME/.credentials.yaml (or export ${ref} in the environment that launches dsh). ` + `Get a key at https://ai4scholar.net?src=dsh`); } return key; }; return { apiKey, requireApiKey }; } ``` Pattern for scnet-plan: same shape with `ref = 'SCNET_API_KEY'`; `ctx.get('credentials')` (optional access, no `inject` requirement) then `process.env[ref]`. The credential store in this profile is `~/.dsh/.credentials.yaml` (the profile's default provider), with `$DSH_HOME/.credentials.yaml` as the documented location. **Never expose the key to the browser.** Rules that follow: - The route returns `{ ok, code, error }` and data only; on a missing key it returns `MISSING_KEY`, never the value. - For "is a key configured?" use a server route (or `describe`) that answers a boolean plus a masked hint (`…last4`) computed on the host. ai4scholar computes the last-4 hint **host-side** (`credits.js:191`: `configured (…${key.slice(-4)})`) and its browser card only ever calls `describe`. - Do **not** put `apiKey` in your `Config` — the schema is rendered as a form and stored in `cordis.patch.yml`. Config carries the *reference* (`apiKeyEnv`), the value stays in the credential store. - Settings/projected forms redact secrets for remote callers (`SettingsDescribeOptions.redactSecrets`), but that only covers schema-declared secrets; a route is still your responsibility. --- ## 4. Settings namespace + `ctx.tools.register` ### 4.1 The ai4scholar settings comment, and why it does not apply to this build `dsh-ai4scholar/lib/index.js:169-181` (verbatim), written for dsh 0.1.0-rc.7: ```js // From dsh 0.1.0-rc.7 the Settings → Plugins tab renders the intersection // of two ledgers: cards registered under `settings.plugin.item` and the // namespaces the Host's settings service serves. The card is in the browser // bundle; this is the other half — a namespace whose only job is to make // "ai4scholar" a served key, so the key card keeps appearing. The API key // itself stays in the credentials store and never enters settings. On rc.6 // and earlier the tab rendered every card unconditionally and this // registration is inert; in a deployment with no settings service the // callback simply never runs. ctx.inject(['settings'], (settingsCtx) => { const service = settingsCtx.settings; service.register('ai4scholar', Schema.object({}).description('AI4Scholar — configured through its Settings card; the API key lives in the credential store.')); }); ``` **In the running build (`dsh-settings` 0.2.1-rc.2) there is no `settings.register` at all.** Verified three ways: 1. `Service.listService {service:"settings"}` lists only `configure`, `prepareDocument`, `describe`, `update`, `replace`, `mutate`. 2. `grep -rn "settings.register" over all 307 platform packages` → no hits. 3. The Settings → Plugins composition changed shape: the old keyed `settings.plugin.item` slot **does not exist** in this build's slot catalog, and `grep -rn "settings.plugin.item"` over the platform finds no declaration (only the third-party ai4scholar bundle still registers into it — `dsh-ai4scholar/lib/client.js:956` — so that registration waits forever and its card cannot render). What replaces it: - **Settings namespaces are loader entry ids.** `dsh-settings/lib/index.js:432` and `443`: `ns: entry.options.id`, and the entry set comes from `ctx.configEditor.configuration()`, which enumerates the rows under the root include and **drops duplicated ids** (`dsh-config-editor/lib/index.js:30-34`). So the *patch row `id`* is the settings namespace and the key `settings.update(ns, patch, revision)` takes. `Config.listConfigs` shows both spellings: `id: "include:ai4scholar"`, `patchId: "ai4scholar"` — the namespace is the **patchId**. → Consequence for scnet-plan: pick a unique row id (`scnet-plan`), export a `Config`, and the auto-generated configuration form appears (rendered by the Plugin Manager page through `ctx.configForms.describe()/get(id)`, `dsh-client-ui-plugin-manager/lib/client.js:1030-1034`) **with no host-side registration at all**. - **A custom settings card is a slot, not a namespace.** Register into `settings.section` (list, root scope; one nav entry per registration) for a whole page, or `settings.plugins.tab` (list) for a tab inside the built-in Plugins section, or `settings.general.item` for a single preference row. There is no host-side "counterpart" call to make in this build; `settings.configure({ auto: false })` only opts *out* of the auto page. ### 4.2 `ctx.tools.register` shape `ctx.tools.register(definition): () => void` (`Service.listService {service:"tools"}`). Definitions come from `defineTool`: ```js import { defineTool } from '@deepseek-ai/dsh-tools' ctx.tools.register(defineTool({ name: 'scnet_plan_snapshot', // model-facing, unique description: '…', // model-facing parameters: { // typed DSL: string|number|integer|boolean|null|array|object|json|oneOf query: { type: 'string', required: true, description: '…' }, }, output: { schema: { type: 'object', additionalProperties: false, properties: { /* … */ } }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }], }, timeoutMs: 30_000, // declarative only (see below) isConcurrencySafe: (args) => true, presentCall: () => ({ card: 'generic', title: 'SCNet plan', kind: 'read' }), async execute(args, exec) { // exec: { signal, agent, arguments, callId, name, token, return doWork(args, exec.signal) // parent, rootCallId, schema, concludeTurn, deferContext } }, })) ``` - Arguments are validated against `parameters` before `execute`; violations become a normal error result. - `execute` must return exactly the declared output value; the returned disposer of `ctx.tools.register` removes the tool (register it through `ctx.effect`/directly in `apply` — both are fiber-scoped). - `timeoutMs` is **not enforced** by the registry; enforcement requires `@deepseek-ai/dsh-tool-call-timeout-policy`. Pass `exec.signal` to your fetch calls regardless. - The registry feeds the model's schema automatically — no extra prompt registration. Optional extras seen in ai4scholar: `ctx.systemPrompt.section({name, order, text})`, `ctx.commands.register({name, description, handler})` (inside `ctx.inject(['commands'], …)`). --- ## 5. Client bundle format — exact ### 5.1 The wrapper Every browser bundle is one classic script that registers a lazy factory. There is exactly one accepted shape: ```js window.__ModuleLoader__.load({ id: '', factory: (require) => { var module = { exports: {} }; var exports = module.exports; Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); // optional (tsdown emits it) const React = require('react'); const h = React.createElement; // … component definitions … function apply(ctx) { /* registrations */ } const inject = ['slots', 'locale', 'layout']; exports.apply = apply; exports.inject = inject; return module.exports; }, }); ``` - `id` is normalized by `stripClientSuffix` (`dsh-client-modules/lib/client.js:98`): `` and `/client` are the same module. **It must equal the package name** — the loader looks the factory up by graph row id and otherwise fails with `loaded without registering "" via __ModuleLoader__.load` (`lib/client.js:758`), which fails that plugin's load (and cascades to its dependents). - A second registration of the same id throws `duplicate factory registration for "" (bundle executed twice without invalidate?)` (`register()`, `lib/client.js`). - `factory` runs **once, lazily, on first import and is memoized**. Keep it side-effect free except defining components; do all registration in `apply`. - Package-local dynamic chunks are optional and not needed for scnet-plan. A chunk is a separate file beside the bundle that registers itself as `window.__ModuleLoader__.load({ id: '', chunk: 'client.foo.js', factory })`; the file name must match `/^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/`, and consumers load it with `await require.async('./client.foo.js')` (`register`/`makeRequire`/`importChunk`, `dsh-client-modules/lib/client.js:98, 832, 852`). - `exports.apply(ctx)` and `exports.inject = [...]` are the exported plugin face the client cordis Loader mounts. `inject` names **client services** (`slots`, `locale`, `layout`, `theme`, `remote`, `sessions`, …); the entry is created with a bare `{ name: id }` (`ClientEntries.create`, `dsh-client-modules/lib/client.js:483`), so `dsh.client.inject` (packages) and `exports.inject` (services) are different things. - Hand-written works: `@ggboy123/dsh-paper-reader/lib/client.js` and `dsh-skill-starter/client.js` are shipped unbundled; the official template is too. No bundler, transpiler, or JSX is required — use `React.createElement`/`h`. - Styles: emit a `