# Developing a DSH Plugin, Explained Through This Repository

English | [中文](plugin-development.zh.md)

This is a companion to the [main README](../README.md): where that document explains *what this plugin does*, this one explains *how DSH/Cordis plugin development works*, using this repository's actual code as the running example. It is intentionally brief — for the full concept walkthroughs, follow the official docs linked throughout.

**Primary references** (start here for anything not covered below):

- [DeepSeek Harness repository](https://github.com/deepseek-ai/deepseek-harness) — source of truth for the framework.
- [Your first Harness plugin](https://deepseek-harness.github.io/deepseek-harness/en/develop/basic/) — the shortest path to a working plugin.
- [Package and install a plugin](https://deepseek-harness.github.io/deepseek-harness/en/develop/basic/publish) — the bundle/profile mechanics this repository's `package.json` and `cordis.patch.yml` follow.
- [Services and dependencies](https://deepseek-harness.github.io/deepseek-harness/en/develop/framework/service) / [Event system](https://deepseek-harness.github.io/deepseek-harness/en/develop/framework/events) — the two extension points this plugin uses (`ctx.on('llm/stream', ...)`, `ctx.on('session/event', ...)`).
- [Cordis tutorial](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cordis-tutorial/index.md) — Cordis itself, the plugin runtime underneath Harness, taught hands-on from scratch.

## 1. A plugin is a bundle: `package.json` + `cordis.patch.yml`

Per [Package and install a plugin](https://deepseek-harness.github.io/deepseek-harness/en/develop/basic/publish), an installable plugin is an npm package (a **bundle**) whose manifest declares `dsh.bundle.patch`, pointing at a YAML file that inserts one or more rows into the composed configuration:

```jsonc
// package.json (this repository)
{
  "dsh": {
    "bundle": { "patch": "./cordis.patch.yml" },
    "client": {
      "platform": "web",
      "inject": ["@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation"]
    }
  }
}
```

```yaml
# cordis.patch.yml (this repository)
- insert:
    - id: llm-wire-trace
      name: dsh-llm-trace-plugin
      # config:
      #   maxRecords: 200
```

`name` here is the **package name** — Node resolution finds the installed code — not a file path. A user installs it with `dsh plugin --profile <name> add dsh-llm-trace-plugin` (or `link:.` for local development, or a git spec); see the README's [Install](../README.md#install) section for every supported form.

The optional `dsh.client` block is how a package tells the loader it *also* has a browser half; see §3.

## 2. Host half: a plugin is `apply(ctx, config)`

Every Cordis plugin — dynamic or file-based — is, at minimum, a module exporting `name` and `apply`:

```ts
// src/host/index.ts (abridged)
export const name = 'llm-trace-plugin'

export function apply(ctx: PluginContext, config?: PluginConfig): void {
  // ...
}
```

- **This plugin deliberately declares no top-level `inject`.** `inject` names hard dependencies, and a Cordis fiber that has any stays *inactive* until every one of them exists — so `apply()` would wait for the `webServer` service before it ever installs the `fetch` patch. That wait is fatal for a plugin whose whole job is to be in place *before* other plugins evaluate their own modules: the Loader starts every entry concurrently at mount and imports an entry's module when that entry starts, so a gated `apply()` normally runs after the module bodies of every other entry have already executed. A provider plugin that captures `globalThis.fetch` at module scope (see the README's [Known limitation](../README.md#known-limitation)) has snapshotted the unpatched `fetch` by then, and nothing this plugin does later can reach it.
- **Services are reached per use, with `ctx.inject(deps, callback)`.** The callback runs — in a child fiber tied to this plugin — once those services exist, so a service that arrives late, or never arrives at all, delays only the wiring that needs it and never the patch:

  ```ts
  // src/host/index.ts
  ctx.inject(['sessions'], (sessions) => {
    sessions.on('session/event', (session, event) => tracker.observe(String(session.id), event))
  })
  ctx.inject(['llm'], (llm) => {
    llm.on('llm/stream', (options, next) => bindLlmStream(options, next, tracker))
  })
  ctx.inject(['webServer'], (web) => {
    web.effect(() => web.webServer.register({ kind: 'prefix', path: routePrefix, handler: ... }))
  })
  ```

  Note which context each part uses: the route handler still reads `credentials`, `configEditor` and `fiber.entry` off the OUTER `ctx`, because `ctx.fiber` must be *this plugin's own* Loader entry for a config write to target its own row — the injected child fiber has none of its own.

- **`ctx.get(name)`** is the other way to reach a service, for an optional one whose absence should degrade gracefully rather than block. It is only meaningful when the read happens at use time (as this plugin's `getCredentials`/`getConfigEditor` callbacks do): a check made while `apply()` runs, such as `if (ctx.get('sessions') !== undefined)`, answers for that instant — and with no `inject` gating `apply()`, that instant is now earlier than the services it asks about.
- **`ctx.effect(fn, label?)`** registers a side effect with an automatic disposer, so stopping or updating the plugin cleanly undoes it. This plugin uses it for the `fetch` patch, and for the HTTP route inside its `webServer` injection:

  ```ts
  // src/host/index.ts
  ctx.effect(() => installFetchPatch((real) => store.wrapFetch(real)), 'llm-wire-trace: fetch patch')
  ```

This repository additionally defines its own `PluginContext` type (a minimal structural subset of the real `Context`) instead of importing `@deepseek-ai/cordis` — see the doc comment on that interface in `src/host/index.ts` for why: it keeps the published package free of a host-runtime type dependency. A plugin developed inside the monorepo (following the official tutorials) would instead `import type { Context } from '@deepseek-ai/cordis'` directly, which is simpler and is what you should do unless you have the same "ship standalone, no monorepo" constraint.

## 3. Client half: Slots and the classic-script bundle format

The browser side of a plugin registers into a **Slot** — a named extension point the shell's React tree renders. This plugin injects one tab into the session view ring:

```ts
// src/client/entry.ts (abridged)
function apply(ctx) {
  ctx.slots.inject('conversation.view', () =>
    ctx.slots.register({ name: 'conversation.view', id: 'wire-trace', priority: 100, label: () => 'Wire Trace' }, WireTraceView),
  )
}
```

Two things about this half are specific to *this* plugin's distribution constraints, not general requirements:

- **No JSX, no `import`.** The compiled `dist/client.js` is loaded as a classic script via `window.__ModuleLoader__.load({ id, factory })`, so every element is built with `React.createElement` and `react` itself arrives through the factory's `require`, not an ES import. A plugin developed from a monorepo checkout, following the official tutorials, typically does not need this — check the current client-plugin authoring guide in the [DeepSeek Harness repository](https://github.com/deepseek-ai/deepseek-harness) for the supported approach there.
- **Host↔Client is Package-private JSON RPC**, not shared memory: the two halves run in different processes (Node vs. browser) and communicate only through `harness.handle(method, handler)` (host) / `host.call(method, args)` (client) — or, for this plugin specifically, through the ordinary HTTP routes `src/host/routes.ts` registers, fetched by `src/client/api-client.ts`. Either channel only ever carries lossless JSON.

For Slot registration details beyond this example (exact prop shapes, other extension points, theme tokens), inspect the live Slot tree and its registration contract rather than guessing — that is what the `cordis-plugin-development` Skill and its Inspect Providers are for when working inside a DSH session.

## 3a. The `locale` service: making the tab's language follow DSH's own setting

DSH ships a first-party client plugin, `@deepseek-ai/dsh-client-locale`, providing a `ctx.locale` service. This plugin injects it (`inject: ['slots', 'timer', 'locale']`) so the Wire Trace tab's text switches with DSH's own Settings → General → Language toggle, instead of rendering one hardcoded language regardless of that setting:

```ts
// src/client/entry.ts (abridged)
const NS = 'llm-wire-trace'

function apply(ctx) {
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'llm-wire-trace: locale dictionaries')
  const t = ctx.locale.bind(NS)
  // t is then handed to the rest of the client half as a plain parameter —
  // no other module touches ctx.locale directly.
}
```

- **`ctx.locale.register(ns, { zh, en })`** registers one namespace's two dictionaries — flat `Record<key, string>` maps with `{name}`-style placeholders (see `src/client/strings.ts`). The typed call site requires both locales to declare exactly the same key set, so a missing translation is a compile error, not a silently blank label.
- **`ctx.locale.bind(ns)`** returns a stable `t(key, params?)` function that reads the *currently active* locale at call time — bind it once and reuse the same reference; there is no need to re-bind after a language switch.
- **`ctx.locale.subscribe(fn)`** fires whenever the active locale changes. `wire-trace-view.ts` uses it to force a re-render of the already-mounted tab the moment the user flips the language, rather than only picking up the change on the next remount — the same pattern `@deepseek-ai/dsh-client-ui-conversation` (already injected by this plugin's `dsh.client.inject`) uses for its own views.

Every other client module (`format.ts`, `json-view.ts`, `wire-trace-view.ts`) receives `t` as an ordinary function parameter and has no idea `ctx.locale` exists — `entry.ts` is the only file that touches it, following the same "framework glue in exactly one file" rule this repository already applies to Slots and to the host's Cordis usage. See [`architecture.md`](architecture.md#client-half-data-model--adapters--view) for how `strings.ts`/`format.ts` are split to make that possible.

## 3b. `plugins.bundle.config`: this plugin's own content on DSH's Plugins page, and self-editing `Config` through `configEditor`

Three more things combine to make "every `PluginConfig` parameter is editable from DSH's own Plugins page" work, none of them specific to this plugin's standalone-distribution constraint — any Cordis plugin can use all three:

**A `Config` export is the generic Cordis contract for a validated, inspectable configuration.** `src/host/config.ts` exports both the `PluginConfig` TypeScript interface and a runtime schema of the same name, built with `@deepseek-ai/schemastery` (a small, standalone validator published independently of `@deepseek-ai/cordis` — using it does not reintroduce the host-runtime Cordis dependency this plugin otherwise avoids):

```ts
// src/host/config.ts (abridged)
import z from '@deepseek-ai/schemastery'

export interface PluginConfig {
  maxRecords?: number
  // ...
}

export const Config: z<PluginConfig> = z.object({
  maxRecords: z.number().step(1).min(1).default(200)
    .description('In-memory ring-buffer size: how many recent records stay resident regardless of persistence.'),
  // ...
})
```

Cordis validates a plugin's `config:` against an exported `Config` at activation — a typo'd key or an out-of-range value is rejected loudly instead of silently ignored (the exact failure mode a stale `routePrefix` config key caused before this plugin's `ROUTE_PREFIX` became hardcoded; see the README's "Configuration" section) — and the `Config` Inspect provider (`Config.listConfigs`) reads the same schema back for inspection. `src/host/index.ts` re-exports it unchanged: `export { Config } from './config.js'`.

**`plugins.bundle.config` is a free-form Slot, not an auto-generated form.** Declaring `Config` makes the *raw* config validated and inspectable, but DSH's Plugins page does not generate input fields from a schema automatically — a bundle's detail page renders this Slot, keyed by the bundle's own package name, and shows nothing there until some plugin registers into it:

```ts
// src/client/entry.ts (abridged)
import { PACKAGE_NAME } from './constants.js'

ctx.slots.inject('plugins.bundle.config', () =>
  ctx.slots.register({ name: 'plugins.bundle.config', key: PACKAGE_NAME }, PluginManageView),
)
```

The owner props this Slot renders with (`{ view: 'summary' | 'page' }`, only ever `'page'` for the bundle-level Slot) carry no Host-synced form wiring at the bundle level — that richer wiring (`ConfigPageForm`'s `state`/`mutate`) is supplied only for `plugins.row.config`/`plugins.item`, which this plugin does not use. So `plugin-manage-view.ts` talks to this plugin's own `config` route instead (see below), exactly the way `wire-trace-view.ts` talks to `list`/`get`/`clear` — no different, in kind, from any other feature this plugin's client half reads through `api-client.ts`.

**Self-editing a plugin's own config goes through the generic `configEditor` service, addressed by the plugin's own Loader entry.** This is the one new Cordis surface this feature touches: `ctx.fiber.entry` is the Loader `Entry` object for the *exact* composition row this `apply()` run belongs to, and `ctx.get('configEditor')` (an *optional* service — many hosts, including non-Cordis embeddings and ones with no `configEditor` plugin loaded, do not expose one) persists a new config for one entry and reconciles the Loader live, no restart needed:

```ts
// src/host/index.ts (abridged)
handler: createRouteHandler({
  // ...
  getConfigEditor: () => ctx.get('configEditor') as ConfigEditorLike | undefined,
  getSelfEntry: () => ctx.fiber?.entry,
})
```

```ts
// src/host/routes.ts (abridged) — the config route's POST handler
await editor.edit(entry, (current) => {
  const next = { ...current, ...patch }
  schema(next) // re-validate the WHOLE merged config, same Config schema as activation
  return next
})
```

Because `entry` always comes from *this plugin's own* `ctx.fiber`, a write can only ever retarget this plugin's own row — there is no way to reach another plugin's configuration through this route. Both dependencies degrade gracefully when absent (`editable: false` in the `config` route's `GET` response), matching this repository's established pattern for every other optional service (`ctx.get('sessions')`, `ctx.get('llm')`) rather than making the whole route table depend on them.

## 4. Events used by this plugin: `llm/stream` and `session/event`

Per [Event system](https://deepseek-harness.github.io/deepseek-harness/en/develop/framework/events), an event can be a plain broadcast or a **waterfall** — a chain where each listener wraps the next and can transform what passes through. `llm/stream` is a waterfall around every model call; this plugin's listener rewraps the returned stream to bind async-context identity around every pull:

```ts
// src/host/call-context.ts (abridged)
export function bindLlmStream(options, next, tracker) {
  const bound = { sessionId: ..., turn: ..., step: ..., purpose: ..., provider: ..., model: ... }
  const inner = next()
  return (async function* boundStream() {
    const iterator = inner[Symbol.asyncIterator]()
    while (true) {
      const result = await callContext.run(bound, () => iterator.next())
      if (result.done) return
      yield result.value
    }
  })()
}
```

The doc comment right above this function in `src/host/call-context.ts` explains *why* the re-entry happens on every pull rather than once around construction — a subtlety worth reading if you ever wrap a streamed waterfall yourself.

`session/event` is an ordinary broadcast carrying `turn/start` / `step/start` / `step/end` / `turn/end`; `src/host/step-tracker.ts` folds those into "what turn/step is open right now, per session" — a small, independently testable state machine with no Cordis dependency at all.

## 5. What is *not* ordinary Cordis: the `fetch` patch

Everything above is standard extension-point usage. The one deliberately unusual thing this plugin does — patching `globalThis.fetch` in the real Node process — is **not** something a dynamic Cordis plugin (defined at runtime through `cordis_define`) can do at all: a dynamic package runs inside an isolated `node:vm` realm whose `globalThis` is not the process's real one, and whose `fetch` is a throwing trap by design. This only works because the plugin ships as an **installed package**, loaded into the real process. See the module doc at the top of `src/host/fetch-patch.ts` for the full reasoning — it is the one place in this repository where the plugin steps outside what the framework's own extension points offer, and it explains exactly why that step is safe and why it must be a real package rather than a runtime-defined one.

## Where to go next

- Building your own first plugin: [Your first Harness plugin](https://deepseek-harness.github.io/deepseek-harness/en/develop/basic/).
- Deeper on services, isolation, and dependency behavior: [Services and dependencies](https://deepseek-harness.github.io/deepseek-harness/en/develop/framework/service).
- Deeper on events, waterfalls, and short-circuiting: [Event system](https://deepseek-harness.github.io/deepseek-harness/en/develop/framework/events).
- Cordis itself, from a scratch directory with no API key needed: [Cordis tutorial](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cordis-tutorial/index.md).
- [`architecture.md`](architecture.md) — why this repository's own modules are split the way they are, and how data flows between them.
- This repository's own module-by-module layout and design rationale: the [main README](../README.md), particularly its "Repository layout" and "How it works" sections.
