SponsorLobeHubLobeHubLearn more
dshfind

Lesson 11: Plugin Anatomy: What a DSH Package Looks Like

One-liner: In DSH, "giving the agent a new capability" is not about changing the source code — it's about writing a package — exporting name (who I am), inject (what I need), and apply (what I contribute) from src/index.ts, registering it in cordis.yml, and the framework instantiates it with ctx.use into a lifecycle-bearing fiber: effective on load, restored on unload.


1. User Story: "Adding a New Capability" in DSH

Imagine you have a machine with DSH installed, running the Web UI. Now you want to give the agent a new skill: for example, a greet tool that says hello, or a metrics service that keeps accounts for other plugins. In a traditional framework, you might have to fork the source code and modify the main loop; in DSH, you only need to do three things:

  1. Write code: create a new TypeScript package containing a plugin file;
  2. Register: declare it in an assembly file called cordis.yml;
  3. Start: the framework loads the plugin and the capability takes effect immediately.

First, here is the official tutorial's definition of "what a plugin is" (source: docs/user/develop/basic/index.zh.md):

In Harness, a plugin is a TypeScript module that exports an apply function. When loading, the framework calls apply and passes in a ctx (context object); you register capabilities through ctx.

The simplest plugin looks like this — this is the entire structure:

import type { Context } from 'cordis'

export const name = 'my-plugin'

export function apply(ctx: Context) {
  // Register capabilities here.
}

(Source: docs/user/develop/basic/index.zh.md)

Three elements, unpacked one by one:

ElementWhat it isIn plain words
nameThe plugin's name, used by the loader for diagnostics"Who I am"
apply(ctx)The effect function called by the framework on load"What I do" — register capabilities on ctx
ctxThe context objectThe "shared blackboard" between the plugin and the system

If you need to use capabilities provided by other plugins (such as the tool registry tools), add one inject line:

import type { Context } from 'cordis'

export const name = 'my-tool-plugin'
export const inject = ['tools']

export function apply(ctx: Context) {
  // ctx.tools is ready here.
  ctx.tools.register(/* ... */)
}

(Source: docs/user/develop/basic/index.zh.md)

inject means "I depend on these things" — the framework will guarantee these dependencies are ready before executing your apply. If they are not ready yet, the plugin waits; it will not run early.

💡 Remember this minimal skeleton: name + inject + apply. Every section that follows adds something onto this skeleton.


2. Physical Structure of a Package: Directories, Files, and Assembly Registration

"Writing a package" — where exactly do you put the files? The repository's practical manual gives a file-by-file checklist (source: docs/cookbook/adding-a-package.md):

packages/<group>/<pkg>/
  package.json     # Package name, dependencies, build artifact entry
  tsconfig.json    # TypeScript compilation configuration
  src/index.ts     # Service default export or plugin (name/inject/apply/Config)
  README.md        # Service API, events, extension points, design notes

Here, the "group" is just a pure container — the repository classifies packages by capability family into groups such as core, llm, shell, compaction, subagent, todo, util, and each package sits exactly one level under a group. The three core files each have their own job:

FileWhat it doesAnalogy
src/index.tsAll the plugin's logic: exports name / inject / apply, or default-exports a service classThe engine
package.jsonPackage name (shaped like @deepseek-ai/dsh-xxx), version, dependencies, build entryNameplate and ingredient list
README.mdHuman-facing manual: API, events, design notesUser manual

What the real repository looks like: the fs capability family

Open the packages/fs/ directory of the DSH repository and you will see one capability split into multiple packages — this is exactly the "seam" idea from Lesson 2 put into practice (source: packages/fs/README.zh.md):

PackageRolectx key
fs/Service Definition: normalizes paths, text I/O, atomic mutation primitives; owns fs/* policy eventsctx.fs
fs-local/Local filesystem implementation(registers ctx.fs)
fs-sandbox/Enforcing-sandbox implementation: constrains writes/edits by mode and workspace-root policy(registers ctx.fs)
fs-observation-policy/Policy gate plugin: provides read-before-edit etc. through fs/* event gates(no service, listeners only)
tool-fs/Model-facing read/write/edit tools and executors(registers into ctx.tools)
tool-fs-search/Model-facing glob/grep discovery tools(registers into ctx.tools)

Note the division of labor: the definition (fs/) only specifies "what the filesystem capability looks like"; the providers (fs-local/, fs-sandbox/) each implement it; the consumer (tool-fs/) only registers tools for the model. Want to swap the sandbox implementation? Just swap in another provider package — the definition, policy, and tool schemas need zero changes.

Registering into cordis.yml: "installing" the plugin into the system

The package is written — how does it get into a running DSH? The answer is the assembly file cordis.yml. During local development, use insert to plug it into the current assembly (source: docs/user/develop/basic/index.zh.md):

- insert:
    - id: hello
      name: './src/my-plugin.ts'

Then start with this overlay:

pnpm run dsh web --patch ./scratch-plugin/cordis.yml

The repository's bundled examples/web-cordis/cordis.yml follows the same pattern — inserting @deepseek-ai/dsh-tool-cordis with insert (source: examples/web-cordis/cordis.yml). A production assembly is a list of such plugins ordered in sequence; the framework loads them by the map, resolves dependencies, and instantiates them.

📦 Want to publish this package as an official package others can install? That is the "practical manual"'s job: docs/cookbook/adding-a-package.md provides a complete file-by-file checklist (package.json invariants, root configuration registration, verification commands pnpm run constraints && pnpm run typecheck && pnpm run build). This lesson first clarifies "what a package looks like"; the next chapter will have you write one by hand.


3. The Core of Component Definition: the fiber of inject, apply, and ctx.use

Now let's replace the word "plugin" with its formal name — component. In the Lesson 2 paper reading we learned: a component definition is assembled from two halves:

HalfFormal nameEveryday phrasingWhat it looks like in code
injectDependency declaration dWhat I needexport const inject = ['tools', 'fs']
applyEffect function eWhat I contributeexport function apply(ctx, config) { ... }

This is what a real production package in DSH looks like (source: packages/fs/tool-fs/src/index.ts):

/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-fs'

/** Services required by the filesystem tool suite. */
export const inject = ['tools', 'fs', 'systemPrompt']

/** Register the full `read`/`write`/`edit` filesystem tool suite. */
export function apply(ctx: Context, config: Config): void {
  // schemastery (Config) has already filled every defaulted field.
  const resolved = config as ResolvedConfig
  assertPositiveInteger('readLimit', resolved.readLimit)
  applyReadTool(ctx, { /* ... */ })
  const sandbox = new FsSandboxSurface(ctx)
  applyWriteTool(ctx, sandbox)
  applyEditTool(ctx, sandbox)
}

(Source: packages/fs/tool-fs/src/index.ts, some implementation details omitted)

Reading this real code: it declares "I need tools (the tool registry), fs (filesystem capability), and systemPrompt (system prompt assembly)", then in apply it contributes three tools at once (read, write, edit). Note that apply can also receive a second parameter, config — the plugin uses it to accept user configuration (tool-fs declares its config fields and defaults with schemastery's z.object).

ctx.use: "instantiating" a component definition into a fiber

name + inject + apply are only the blueprint. Turning a blueprint into a running machine is the job of ctx.use — it instantiates the component into a fiber, a runtime object with a full lifecycle:

组件定义inject(我需要)依赖声明 dapply(我贡献)效应函数 ectx.use实例化fiber(运行时)parent / ctx(子上下文)epoch(目标状态版本)dispose(累积逆函数)inertia(迁移句柄)

组件 = 声明我需要什么 + 贡献什么;ctx.use 把它变成带生命周期的 fiber

The five things a fiber carries are exactly the code forms of those concepts from the Lesson 2 paper:

Field on the fiberWhat it storesName in the paper
parentThe parent context — who instantiated itLevel of the context tower
ctxThe child context derived from the parentThe component's own blackboard
epochThe "version number" of the target state; changes when dependencies change𝜀𝑑(𝜎)
disposeThe accumulated "undo list" (inverse functions)recover
inertiaThe in-flight migration handleInertial state machine
  • Dependencies changeepoch changes → the framework decides whether to reload or unload this fiber;
  • Unload → executes the accumulated inverse functions in dispose → everything the component registered is undone;
  • Once a migration starts, it runs to completion — that is "inertia".

🔗 This is precisely the mechanism taught in Lessons 10 and 11 of the Chapter 2 "paper reading": a component = declare what I need + what I contribute, and ctx.use turns it into a lifecycle-bearing fiber. Every plugin package in DSH is, in essence, one or more component definitions — what you are seeing now is this theory used for real in the production repository.


4. Tools, Services, and Lifecycle: Register into the System, Restore on Unload

4.1 Tools: registering into ctx.tools (schema + executor)

The most common kind of plugin is a tool plugin: registering a "spec sheet + executor" on ctx.tools. This is the official tutorial's greet tool (source: docs/user/develop/basic/tool.zh.md):

import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'greet',
    description: 'Greet someone by name.',
    parameters: {
      name: { type: 'string', required: true, description: 'The name to greet' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args) {
      return `Hello, ${args.name}!`
    },
  }))
}

(Source: docs/user/develop/basic/tool.zh.md)

defineTool defines a tool as three parts: parameters (the parameter schema — the "spec sheet" the model sees, which also infers and validates the type of args), execute (the executor that actually does the work), and output (a standard schema declaring the return value plus render, which renders the value into model-visible content). After registration, the schema automatically flows into the system prompt assembly — the model can see and call this tool on its next request.

4.2 Services: the three roles of definition, provider, and consumer

If you want other plugins to use your capability, provide a service (source: docs/user/develop/framework/service.zh.md):

A service is a capability that a plugin exposes to other plugins. inject declares which services a plugin needs. In Harness, tools, llm, and agents are all services — a service is a named capability mounted on ctx.

Provide a service with the Service base class — remember that ctx.fs in tool-fs? It is provided by someone else using Service:

import { Service, type Context } from 'cordis'

export default class MetricsService extends Service {
  static inject = ['llm']  // A service may depend on other services.

  constructor(ctx: Context) {
    super(ctx, 'metrics')  // 'metrics' is the service name.
  }

  // Public service method.
  record(event: string, value: number) {
    // ...
  }
}

(Source: docs/user/develop/framework/service.zh.md)

Once this plugin is loaded, consumers can access it through ctx.metrics:

export const inject = ['metrics']

export function apply(ctx: Context) {
  ctx.metrics.record('tool_call', 1)
}

(Source: docs/user/develop/framework/service.zh.md)

Every capability has three roles (as covered in Lesson 2):

Service Definition (capability definition: what this capability looks like)
        ↕
Service Provider (provider: who does the work)
        ↕
Consumer (consumer: who uses it)

Back to the fs capability family table in Section 2: fs/ is the Definition, fs-local/ and fs-sandbox/ are the Providers, tool-fs/ is the Consumer — three ends separated, and any end can be replaced independently. The repository manual's exact words (source: docs/cookbook/adding-a-package.md):

For replaceable capabilities, when the Service Definition / Service provider / Consumer roles need to evolve independently, split them into separate packages — the bash three-component set is the template.

4.3 Automatic lifecycle management: effective on load, restored on unload

After a component is registered into the system, you don't manage its lifecycle. The official tutorial's exact words (source: docs/user/develop/basic/index.zh.md):

Anything registered through ctx — event listeners, tools, timers — is automatically cleaned up when the plugin unloads. You don't need to manually removeListener or clearInterval.

This is the fiber's dispose at work: a tool registration is itself a side effect — unloading the plugin = disposing the fiber = automatically unregistering the tool. From the tool reference manual (source: docs/cookbook/adding-a-tool.md):

Registration is side-effect based: disposing (releasing resources) the plugin fiber unregisters that tool.

Dependency lifecycle is equally automatic (source: docs/user/develop/framework/service.zh.md): if a required service disappears while the app is running (for example its provider unloads), plugins depending on it are automatically disposed; when the service reappears, the plugin automatically reloads.

Only a few resources that need manual management (such as a network connection) use ctx.effect() to tell the framework how to clean up — note that the function it returns is exactly the "undo instructions":

export function apply(ctx: Context) {
  ctx.effect(() => {
    const timer = setInterval(() => {
      console.log('heartbeat')
    }, 5000)

    // The returned function runs when the plugin unloads.
    return () => clearInterval(timer)
  })
}

(Source: docs/user/develop/basic/index.zh.md)

🔁 Echoing Chapter 2: Cordis's "spacetime composability" promise is install and remove, remove without a trace — effective on load, restored on unload. No matter how complex a plugin gets, every change it makes to the system is bookkept and settled in one go on unload. This is the confidence that lets DSH let agents "self-modify".


Key Points Recap

  • A plugin = a TypeScript module that exports an apply function: name (who I am), inject (what I need — the framework guarantees readiness before executing), apply (what I contribute — registering capabilities on ctx).
  • The physical structure of a package: located under packages/group/pkg-name/, with the core files src/index.ts, package.json, and README.md; assembly is registered via the insert in cordis.yml (dsh web --patch for local effect).
  • A component definition = inject (dependency declaration d) + apply (effect function e); ctx.use instantiates the definition into a fiber carrying five lifecycle fields: parent / ctx (child context) / epoch / dispose / inertia.
  • Tools and services: a tool = ctx.tools.register(defineTool({ parameters, execute, output })); a service = the Service base class mounted on ctx, with the capability split into the three roles Definition / Provider / Consumer.
  • Automatic lifecycle management: effective on load, restored on unload — tool registrations, event listeners, and timers are all cleaned up automatically with the fiber's dispose; dependencies disappearing auto-unload, reappearing auto-reload; ctx.effect() handles the few manual resources.
  • Publishing is the last mile: follow the file-by-file checklist in docs/cookbook/adding-a-package.md to complete the manifest and verification, and it becomes an installable @deepseek-ai/dsh-xxx package for others.

🚀 In this lesson we went through a "package" from the outside in: directories, files, component definitions, fibers, tools and services. In the next chapter, "Chapter 4 · Plugin Development in Practice", we stop looking at code and start writing it by hand: create your first plugin from scratch, run it in the Web UI, then step by step learn configuration, hot reload, and publishing.

Self-check · Plugin Anatomy

Answer each question, then submit to check your result.

1. Which of the following statements about inject and apply in a plugin is correct?
2. What does ctx.use turn a component definition into?
3. Which three roles make up a replaceable capability (seam)?
4. Which of the following statements about a DSH package's physical structure and assembly is correct?