When you first write plugin code in DeepSeek Harness, what actually trips you up is often not TypeScript syntax, but three specific questions: what exactly does the apply function receive, what can the ctx context object do, and why does a plugin you've written get loaded by the framework. These three questions correspond respectively to the three core keywords of this tutorial—apply, ctx, and cordis.yml. Many people imagine plugin development to be a complicated affair, but in reality, in Harness, a valid plugin is nothing more than a TypeScript module that exports an apply function and a name constant; when the framework loads it, it calls apply and hands you a Context object, through which you register capabilities, and the rest is handed back to the framework. As Part 1/2 of this article, this section will first thoroughly explain "what a plugin is": starting from breaking down the apply function signature, clarifying the dual identity of name and the registry semantics of ctx, then walking you through installing Harness from source, setting up a scratch-plugin experiment directory, writing a minimal plugin with zero omissions, and finally connecting the two pieces of the loading chain—the patch overlay cordis.yml and the --patch startup parameter—into a complete picture. After reading this, you should be able to independently write a local plugin that can actually be loaded and run by the Web UI, rather than staying at the level of copy-pasting.
Parsing the apply function signature: importing the Context type from @deepseek-ai/cordis
In the worldview of Harness, a plugin is not some giant beast that needs to inherit a base class, implement an interface, and register itself into a global singleton; it is just an ordinary ES module file. To determine whether a module is a plugin, the framework only looks at two things: whether it exports apply, and whether apply receives a context parameter according to convention. The benefit of this design is that plugins are naturally tree-shakable, easy to test, and easy to load on demand, while also compressing the matter of "plugin initialization" into the instant when a function executes.
First, look at the signature of apply. The official way to write it is: the plugin module imports the Context type from @deepseek-ai/cordis, and then exports an apply function that receives ctx: Context. Please note the source of this type—it is not imported from the deepseek-harness main package, but from cordis, the underlying container library. This point is crucial, because Harness's plugin system is built on top of Cordis, a dependency injection and lifecycle container; Context is Cordis's core abstraction, and Harness merely extends its capabilities into the semantics of an "Agent runtime."
Why emphasize "importing the Context type from @deepseek-ai/cordis"? Because TypeScript type imports disappear after compilation; they add no dependency burden at runtime, but the benefits they bring to developers are enormous: the methods, properties, and generic constraints available on ctx will all be automatically completed in the editor, and if you mistype a method name or pass the wrong parameter type, the IDE will immediately mark it in red. Getting the type import right is equivalent to obtaining a key to the entire ctx API; when you later register event listeners, register tools, or register LLM adapters, you can rely on type hints instead of repeatedly digging through documentation.
You also need to pay attention to the return value of apply. In a minimal plugin, apply usually returns nothing; it is just a synchronous function that performs side effects. The framework calls it, it completes registration, and then the lifecycle continues forward. This "one-time initialization" semantics determines that apply is not suitable for long-running blocking logic. If you need to fetch remote configuration or establish a connection, you should consider handling it with asynchronous registration or a later hook, rather than waiting synchronously inside apply. The material explicitly mentions that the framework calls apply when loading a plugin, but it does not require apply to be async. Therefore, at the beginner stage, keeping it synchronous and delegating asynchronous work to a callback after registration is the safest approach.
Also, a common beginner misconception: many people naturally want to add a second or third parameter to apply (such as "config" or "options"), expecting the framework to inject configuration. According to the current material, the input parameter of apply is simply ctx: Context. The dependencies required by the plugin are already in place before apply executes, which means the context itself is the unified entry point for dependencies and configuration. You do not need to, and should not, extend the function signature. To read configuration, you should use the mechanism provided by ctx, not invent your own parameters.
The dual identity of the exported name field: log identifier and configuration reference key
In a minimal plugin, besides apply, another required export is the name constant. The material defines it very restrainedly: name is the plugin name, used to identify this plugin in logs and configuration. This short sentence actually points out the dual identity of name, and understanding this can help you avoid a whole series of later troubleshooting hell where "it loaded but does not take effect."
The first identity is log identifier. Harness is a framework built around the Agent runtime, and the runtime generates a large number of events, calls, registrations, and error messages. When the framework decides to print a log line, it needs to know "who said this log line." If your plugin exports name = 'hello-plugin', then all output related to this plugin can carry a prefix like [hello-plugin], and you can pick its logs out from thousands of lines at a glance in the terminal. Conversely, if you do not export name, logs can only degrade into anonymous output that cannot be traced, and when multiple plugins are mixed together, debugging becomes almost impossible. Therefore, giving a plugin a semantically clear, globally non-conflicting name is an engineering standard, not an optional item.
The second identity is configuration reference key. Plugin systems usually allow plugins to be parameterized, enabled/disabled, and have their settings overridden in configuration files. The framework needs to locate "which plugin I want to pass parameters to" in the configuration, and it relies on name. In other words, name is the "ID number" you publish externally when writing a plugin, and the configuration side uses this name to find you. Once name is released, it is not recommended to change it casually, otherwise all configurations that reference it will fail to match, and the plugin will silently fail to take effect or report confusing errors.
Here is a pitfall that is easy to stumble into: many people conflate the file name my-plugin.ts with the plugin name hello-plugin, assuming the two must match. In reality, they are two separate things—the file name is a path at the file system level, and an absolute path is used when loading; name is a logical identifier at the framework level, used for logging and configuration. Having a file called my-plugin.ts and a plugin called hello-plugin is perfectly legal, and the example in the material is written exactly that way. Conversely, however, keeping the file name semantically close to name (for example, a file named my-plugin.ts with a name of hello-plugin, a kind of same-family naming) can significantly reduce the cognitive burden during team collaboration.
To clearly separate the responsibilities of name and the loading path, you can refer to the table below:
| Comparison Item | name export field | Plugin file path |
|---|---|---|
| Level it belongs to | Framework logic layer | File system layer |
| Typical value | A short identifier such as hello-plugin | An absolute path ending in .ts |
| Primary use | Log prefix, configuration reference key | Tells the framework where to load the code from |
| Must it be unique | Recommended to be globally unique to avoid ambiguity in logs and configuration | Each plugin file path is naturally unique |
| Impact of renaming | Affects log readability and configuration references, so be cautious | Only affects that one path line in the loading configuration |
| Where it appears | A constant export in the plugin source code | The name field in cordis.yml |
After reading this table, if you go back to the line in the material that says “name is the plugin name, used to identify this plugin in logs and configuration,” you will find that it covers two dimensions at once: runtime observability and configuration addressability. A responsible plugin author will, when choosing a name, take into account both “looking good in logs” and “being easy to reference in configuration.”
ctx context object: the entry point for registering capabilities and the resource registry
If apply is the plugin’s “door” and name is the plugin’s “ID card,” then ctx is the entire interface between the plugin and the framework. The material gives a very condensed definition of what ctx means: ctx (Context) is the context object that the framework passes to each plugin; it is both the entry point for registering capabilities and a record of all resources registered by the plugin. These two halves are worth unpacking slowly.
The first half is “the entry point for registering capabilities.” In Harness, the only proper way for a plugin to exert influence on the framework is to register through ctx. The material explicitly lists three typical categories of registration objects: event listeners, tools, and LLM adapters. These three correspond to the three core extension points of the Agent runtime—listening means you can observe and respond to runtime events; tools mean you can add callable capabilities to the Agent; LLM adapters mean you can connect to or transform the model invocation chain. They are all attached to ctx, rather than scattered across various global variables or static classes. This design of “everything goes through the context” allows the side effects of plugins to be uniformly managed and reclaimed by the framework.
The second half is the "resource registry." ctx doesn't just hand you a bunch of register methods and walk away—it also records every resource a plugin has registered. This brings two immediate benefits: first, the framework knows which capabilities each plugin has declared, which makes dependency ordering, conflict detection, and diagnostics easier; second, when a plugin needs to be unloaded or hot-reloaded, the framework has a basis for cleaning up what that plugin registered, without the plugin having to hand-write a pile of unregistration logic (though the context usually provides a matching teardown callback mechanism as well). Understanding ctx as a "registry" rather than a "toolbox" makes you more inclined to go through the proper registration channels when writing code, instead of bypassing the framework to attach global hooks yourself—the latter takes resources out of the registry, and they become ghosts upon unload.
From an engineering practice standpoint, there are several principles around ctx worth remembering:
- Capabilities come only from ctx: if you need listeners, tools, or adapters, register them through ctx; don't build your own global singletons and compete with the framework for management authority.
- Registration is declaration: every registration is a declaration to the framework; the clearer the registration, the less painful subsequent debugging and unloading will be.
- Register on demand: don't blindly register a pile of unused capabilities in apply; the cleaner the registry, the lighter the runtime.
- Make good use of types: because the Context type comes from @deepseek-ai/cordis, the type hints of the registration API will help you catch misuse early.
Many developers migrating from other plugin systems are used to equating a "plugin" with "a class with a lifecycle," and so they hunt everywhere for hooks like onInit and onDestroy. But in the Harness model, a plugin's lifecycle is highly centralized: registration is completed when apply is called, resources are recorded on ctx, and subsequent start/stop and reclamation are coordinated uniformly by the framework based on the registry. Once you understand this, your mindset when writing plugins shifts from "managing my own lifecycle" to "declaring my intent to the framework," and your code will be noticeably shorter and more stable.
Source installation four-hit combo: git clone, pnpm install, pnpm run build, pnpm dsh web
To actually get it running, the first step is to install Harness from source. The material gives a four-step command flow; the order cannot be scrambled, and each step has its reason for existing. Below is the complete command you can paste and execute directly:
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web
Breaking down this chain step by step:
- git clone: pull the complete source from the official repository deepseek-ai/deepseek-harness. The reason "source installation" is emphasized is that plugin development often needs to align with the build artifacts, type declarations, and configuration conventions inside the repository; using a precompiled package directly would lack this context.
- cd deepseek-harness: enter the repository root directory. All subsequent relative paths (including the scratch-plugin we'll create later) are based on this root directory, so be sure to confirm you're standing in the right place; you can use
pwdto check yourself. - pnpm install: install dependencies. Here pnpm is used rather than npm or yarn; be sure to use pnpm, because the repository's dependency topology and workspace structure are organized around pnpm, and using other package managers tends to cause inconsistent dependency resolution.
- pnpm run build: run the build. A source repository usually contains multiple packages, and you need to build the artifacts that the runtime can load first; skipping this step and starting directly often results in module-not-found errors during the loading phase.
- pnpm dsh web: start the Web UI. dsh is Harness's command-line entry point, and the web subcommand gets the interface running so you can observe the effect after the plugin loads in the browser.
Here is a consolidated list of engineering pitfalls and their solutions:
- Pitfall: clone is very slow or fails. Solution: Check your network and proxy configuration, and confirm you can access the deepseek-ai/deepseek-harness repository on github.com.
- Pitfall: install reports dependency conflicts. Solution: Confirm you are using pnpm, and clean up existing lock files and node_modules before reinstalling.
- Pitfall: build reports type errors. Solution: Confirm your Node version meets the repository requirements, and if necessary, reinstall before building again.
- Pitfall: dsh web won't start or the port is occupied. Solution: Check the port information in the startup logs, free up the port, or adjust as indicated by the logs.
- Pitfall: you changed the plugin code but the UI shows no change. Solution: Confirm the plugin loading configuration is correct (see cordis.yml and --patch later), and note that file paths must be absolute.
Beyond memorizing this command flow, it's even more important to understand its significance of order: clone fetches the code, install fills in dependencies, build produces artifacts, and web starts the runtime—each is the prerequisite for the next. Skipping build and going straight to web is the most common rookie crash site of "one command short, half a day of error hunting."
Dependency readiness timing: why all required dependencies are in place before apply executes
There is a seemingly inconspicuous but extremely weighty statement in the material—"the required dependencies are already in place before apply executes." This statement explains a question that has long troubled plugin authors: is it safe for me to directly use capabilities provided by other plugins inside apply? The answer is that, according to the framework's conventions, it is safe.
The underlying mechanism can be understood this way: when loading plugins, a container like Cordis first completes dependency resolution and initialization of prerequisite plugins, confirms that the external capabilities declared as required by the current plugin are already available, and only then calls the current plugin's apply. In other words, the moment apply is called is itself the signal from the framework that "your preconditions have been satisfied." You don't need to write your own polling waits, retry loading, or use setTimeout-delayed registration inside the plugin to gamble that other plugins initialize first.
This timing guarantee brings several very practical benefits:
- Simpler plugin code: no need to write compensation logic in apply like "wait until X is ready, then register Y."
- Avoids races: no occasional failures caused by uncertain loading order—bugs of this kind often can't be reproduced locally yet occur frequently in production.
- Declarative dependencies: you only need to declare dependencies as required by the framework, and loading order is orchestrated uniformly by the container, so during team collaboration there's no need to pass down by word of mouth "remember to start that plugin first."
But you must also correctly understand the boundaries of this statement. It says that "required dependencies" are in place before apply, referring to those dependencies resolved according to the plugin's dependency declarations. It does not mean that "everything in the universe is ready"—for example, the availability of external networks or the first trigger of some runtime event still require you to respond after apply by registering callbacks. Distinguishing "dependency resolution readiness" from "runtime readiness" is a required course for advanced plugin authors: the former is a static guarantee at the loading stage, while the latter is a dynamic fact at the runtime stage.
scratch-plugin directory planning: mkdir -p to set up the src source directory
After installation, we won't touch the repository source code. Instead, we'll create a separate experimental project to hold the plugin. The value of doing this is: completely isolating "learning about plugins" from "modifying the framework", so you can experiment freely without polluting the repository, and when problems arise it's easy to pinpoint whether it's your plugin's code or the framework's own behavior. The material requires first creating the src source directory, with the command below. Please run it in the repository root directory:
mkdir -p scratch-plugin/src
The -p parameter in mkdir -p ensures that "parent directories are created if they don't exist, and no error is reported if they already exist", so a single call creates both scratch-plugin and the src under it, making it suitable for mindless pasting into scripts or documentation. After running it, you can conveniently verify the directory structure:
ls -R scratch-plugin
At this point you should see only a single src level. After we finish writing the plugin and add cordis.yml, the complete structure of scratch-plugin will evolve into the following (this is the final form of the entire experimental project, and later in this section we'll fill in both sides first):
scratch-plugin/
├── src/
│ └── my-plugin.ts # plugin source code (hello-plugin)
└── cordis.yml # patch overlay: tells the framework which plugin to insert
Regarding directory planning, here are a few engineering suggestions:
- Keep the scratch semantics in the directory name: scratch means "draft, experiment", reminding later readers that the code here is not a formal module, avoiding it being mistakenly depended on as production code.
- Put all source code under src: even for a single-file plugin, it's recommended to create src first and then place the file, so that when the plugin grows you can directly add subdirectories without changing the configuration structure.
- cordis.yml at the same level as src: the overlay describes "how to load the plugin in src", and placing it at the project root level is the most intuitive.
- Always run relative-path commands from the repository root directory: because later the "absolute path" in cordis.yml needs to be assembled by you using
pwd, standing in the correct directory helps you make fewer path mistakes.
Minimal runnable plugin my-plugin.ts: a complete configuration with zero omissions
The directory is ready. Next, write the plugin itself. Enter the src directory:
cd scratch-plugin/src
Then write the following content into scratch-plugin/src/my-plugin.ts. The material emphasizes that this is "a complete, usable plugin configuration, missing nothing", and I've also organized it into a directly pasteable version, keeping the key comments:
// File path: scratch-plugin/src/my-plugin.ts
import type { Context } from '@deepseek-ai/cordis'
// name is the plugin name, used to identify this plugin in logs and configuration
export const name = 'hello-plugin'
// apply is the plugin's entry point: the framework calls it when loading the plugin
export function apply(ctx: Context) {
// The required dependencies are already ready before apply executes (see article 9)
console.log('[hello-plugin] plugin loaded!')
}This file is so short it's only a few lines, yet it puts all the concepts covered in the previous sections into practice. It's worth reading line by line:
- import type: Imports types only, producing no runtime dependency after compilation. This is standard TypeScript practice and the import style specified by the material.
- export const name: Publishes the plugin's identity to the framework; logging and configuration both rely on it for identification.
- export function apply: The plugin entry point, which receives ctx and is called at load time.
- console.log: The minimal verifiable side effect. Its value lies in providing visible proof of loading—as long as the log appears, it means the framework really did load and execute this plugin.
Why do we call it "zero omissions"? Because a minimal plugin that can be loaded needs exactly two exports: name and apply. The type import ensures a good editor experience, comments ensure maintainability, and console.log ensures observability—beyond that, nothing else is required. Many beginners, on their first attempt, like to add a pile of things—registering fake tools, registering empty listeners—which instead obscures the basic question of "was the plugin actually loaded?" The correct approach for a minimal plugin is to first make it "seen," then gradually add capabilities.
Let me mention in advance a pitfall you will definitely hit later: this plugin's apply only executes when it is loaded by the framework. Once you've written the file, if you haven't configured a load entry point, running the Web UI will not produce the line [hello-plugin] plugin loaded! in the console. So the next step must be to solve the loading chain, which brings us to cordis.yml and --patch.
Plugin loading chain: the patch overlay cordis.yml and the --patch startup parameter
Loading a local plugin in Harness requires a "two-piece set": one is the patch overlay cordis.yml, and the other is the --patch startup parameter. Concept first: an overlay is a patch-style configuration that does not overturn the original configuration but instead declares "what to insert on top of the original configuration." Our goal is simple—insert scratch-plugin/src/my-plugin.ts into the runtime's plugin list.
First, run pwd in the repository root to get the absolute path. This is a key prerequisite step:
pwd
Then write down the path output by the command, create scratch-plugin/cordis.yml, with the following content (replace /absolute/path/to/ with the real path you got from pwd):
# File path: scratch-plugin/cordis.yml
# This is a Web overlay, responsible only for inserting a local plugin
- insert:
- id: hello
# name is the plugin file path, and it must be an absolute path!
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'Every field in this YAML has a clear responsibility:
- insert: the action type of the overlay, meaning "insert". It does not rewrite existing configuration; it only adds new entries, making it safe and stackable.
- id: hello: the identifier of the inserted entry, used to locate and manage this item within this overlay layer.
- name: absolute path: this is where confusion most easily arises—in the context of cordis.yml, name refers to the plugin file path, not the hello-plugin string exported from the plugin source code. Moreover, it must be an absolute path, a point the material specifically emphasized with an exclamation mark.
Why must it be an absolute path? Because when the framework parses this configuration, its working directory may not be the directory you assume, and a relative path will drift depending on "where it is started from." Using an absolute path eliminates uncertainty, at the cost that this line must be changed when switching machines or directories—so it is suitable for local experimental scenarios like scratch, while formal distribution should use a more stable path strategy.
To make the responsibilities and common pitfalls of the two-piece setup clear, see the table below:
| Comparison item | cordis.yml overlay | --patch startup parameter |
|---|---|---|
| Role | Configuration carrier describing "what to insert" | Tells the framework "which overlay to enable" |
| Required fields | insert, id, name (absolute path) | Path pointing to cordis.yml |
| Common mistakes | Writing name as a relative path | Forgetting to add --patch, causing the overlay not to take effect |
| Impact of modification | Changing the file changes the inserted content | Changing the startup command changes the enabled scope |
| Applicable scenarios | Local plugin experiments, temporary injection | Explicitly declaring the overlay at each startup |
Looking at the two-piece setup together: cordis.yml is the patch that is "written down," and --patch is the switch that "puts it to use." If you only write cordis.yml without adding --patch, the overlay will not participate in the runtime configuration; if you only add --patch without a usable cordis.yml, there is no patch to apply. If either is missing, the plugin will not be loaded, and the line [hello-plugin] plugin loaded! will not appear in the console.
At this point, we have already walked through half of the core chain of the first section: the signature and semantics of apply, the dual identity of name, the registry model of ctx, the source installation command flow, the dependency readiness timing, the scratch-plugin directory, the minimal plugin implementation, and the loading mechanism of cordis.yml + --patch. You now have a complete understanding that can explain "why it is written this way," as well as a plugin and configuration that can be pasted and run directly. The next step, namely part 2/2 of this article, will complete the specific startup command for --patch, verify that the plugin is actually loaded, and on that basis continue to register the first real capability into ctx—event listening and tools—so that your hello-plugin evolves from an "empty shell that can greet" into a plugin that "truly participates in the Agent runtime."
In the previous section, we already pulled down the source code of DeepSeek Harness, installed the dependencies, got the build working, and wrote our first minimal plugin, hello-plugin: it does exactly one thing—export a function named apply, receive the ctx (context object) passed in by the framework, and then print a line of log when apply runs. Once we finished writing it, a question immediately arose: this .ts file sits on disk, so how does the framework know it exists? The answer is the core of this article—use the cordis.yml patch overlay to "plug" the local plugin into the runtime, and then use the --patch startup argument to make it take effect. Below we'll start from the directory structure and go all the way through to load verification and failure troubleshooting.
The final directory tree of scratch-plugin: the coexisting structure of src and cordis.yml
In the previous article, we only created the scratch-plugin/src level. Strictly speaking, that was not yet a "plugin project recognizable by the framework"; it was just an ordinary folder holding source code. To turn it into a unit the framework can load, we must add a cordis.yml, and this yml must be in the same parent directory as the src directory, that is, both must sit under scratch-plugin/.
The final project structure looks like this:
scratch-plugin/
├── src/
│ └── my-plugin.ts # Plugin source code (the hello-plugin written in the previous article)
└── cordis.yml # Patch overlay: tells the framework which plugin to insert
This structure looks so simple that it is almost "bare-bones," but its two parts have completely separate responsibilities, and understanding this is far more important than memorizing the directory itself:
- src/my-plugin.ts is the capability itself. It exports
name(the plugin name, used to identify this plugin in logs and configuration) andapply(the plugin entry point, called by the framework when loading the plugin). It is a pure TypeScript module and does not care who loads it or with what parameters. - cordis.yml is the wiring diagram. It contains no business logic and is only responsible for declaring "please insert which file into the current configuration." It is an overlay layer stacked on top of the framework's existing configuration.
Why put both under the same parent directory scratch-plugin/? Because in real engineering we will maintain multiple experimental plugins at the same time, and each plugin project should be a self-contained directory: source code inside, wiring diagram inside, and it can be deleted as a whole, copied as a whole, or moved as a whole to another machine. If you throw cordis.yml into the repository root, or into src, two typical kinds of confusion arise:
- Putting it in the repository root—when you write experimental plugins you repeatedly overwrite the main configuration, which easily disrupts the main repository's configuration and makes rollback costly;
- Putting it inside src—the directory semantics become ambiguous, and when others read this project they cannot tell at a glance "which layer is the directory convention and which layer is the source code."
The directory tree given in the material places src/ and cordis.yml as sibling nodes. This is not drawn casually, but is the convention of this workflow: one plugin sandbox = one source directory + one patch overlay. Please note that this is not a multi-level nested monorepo structure, but is deliberately flattened to the shallowest possible level, so that anyone newly joining can open the directory and understand the whole picture at a glance.
There is one small detail worth calling out separately: the file name my-plugin.ts does not need to match the plugin's internal name = 'hello-plugin'. The file name is merely a locating coordinate on disk, whereas name is the identity marker inside the framework. In the example from the source material, the two are deliberately inconsistent (the file is called my-plugin.ts, while the plugin is named hello-plugin), which precisely shows that these two namespaces are decoupled—but in a real project, I strongly recommend keeping them in a corresponding relationship, because when troubleshooting you need to jump back and forth between the "file path" and the "plugin name in the logs," and chaotic naming will add a great deal of mental overhead out of nowhere.
The insert directive syntax: the cordis.yml overlay is only responsible for inserting local plugins
Now that the directory is clear, let's write the content. The full contents of scratch-plugin/cordis.yml are as follows:
# File path: scratch-plugin/cordis.yml
# This is a Web overlay, responsible only for inserting local plugins
- insert:
- id: hello
# name is the plugin file path, and it must be an absolute path!
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
Just a few lines, but every part has its rationale. Let's break it down layer by layer:
- The top level is an array (starting with
-in YAML). This means Cordis's patch mechanism supports declaring multiple directives at once, applied in order. In our example there is only one, which is inserting the plugin. - The directive object has only one key, insert. This reflects its positioning in the source material: this overlay is "responsible only for inserting local plugins." It does not modify the parameters of existing plugins, does not override configuration elsewhere, and does not do conditional branching—it does exactly one thing: insert a local file into the runtime.
- The value of insert is itself another array. In other words, multiple plugins can be inserted in a batch at once, and each entry is an object with
idandname.
I especially want to emphasize the point about "single responsibility," because this is the invisible pitfall that beginners are most likely to fall into. Many people, when writing configuration, are used to "conveniently" adding something else to the same yml—such as changing the port or adjusting the log level. In a one-off experiment this looks very efficient, but it has two consequences: first, this overlay can no longer be cleanly deleted (it has been mixed with things you should not have touched); second, when loading fails, you cannot quickly determine whether the problem lies in the "insert plugin" step or the "other changes" step. Treat the patch file as a transaction: do only one thing at a time, verify once done, and only after verification passes consider whether to expand its responsibility.
Another easily overlooked point is YAML indentation. In the file above, the two dash-prefixed entries under insert must be indented, and id and name must in turn be indented one level further relative to the -. YAML is sensitive to indentation; using Tab instead of spaces, or inconsistent indentation levels, will directly cause parsing to fail—and the error message often only says "parse error" without telling you which line's indentation is the problem. It is recommended to use two spaces consistently, and not to enable any automatic formatting plugin other than "Tab to spaces" in your editor to rearrange this file.
The table below lays out clearly "what can be done in the overlay layer, and what we choose to do," helping to establish a sense of boundaries:
| Dimension | cordis.yml as a Web overlay layer | The plugin source itself (my-plugin.ts) |
|---|---|---|
| Core responsibility | Declarative wiring: which file to insert into the current configuration | Imperative logic: export apply and register capabilities |
| Language and form | YAML configuration, pure data | TypeScript module, executable code |
| Key fields | insert, id, name | name, apply(ctx) |
| How changes take effect | Requires the --patch startup parameter to reload | After the plugin is reloaded, the framework calls apply |
| Whether it contains business logic | No, it only performs insertion | Yes, it registers events, tools, LLM adapters, etc. via ctx |
| Risk when removed | Low, removing the whole thing rolls it back | Medium, you must confirm no other plugin depends on the capabilities it registers |
The right-hand column of the table is the part we won't expand on in this article for now—registering event listeners, tools, and LLM adapters by calling ctx inside apply. That is the stage where a plugin truly begins to deliver value. But remember the order: first it must load, then we talk about capabilities. A plugin that registers a pile of capabilities but can't be loaded amounts to zero.
Distinguishing the id and name fields: the plugin path must be an absolute path
There are two fields in an insert entry, id and name. Both names are plain, but their semantics differ greatly, and confusing them will directly cause failures.
id is the identifier for this insert instruction. In the example it is hello. It is used to reference this entry within the configuration system—for example, to distinguish in logs which insert took effect, or for other instructions to point to it in the future. It is a "name," a logical coordinate; what you write is up to you, as long as it is unique within the current file. The example in the material uses hello, which merely looks similar to name = 'hello-plugin' in the plugin file—they are not the same thing.
name is the plugin file path, and it must be an absolute path. This is the most rigid and most error-prone constraint in this article. The material emphasizes it with a comment that includes an exclamation mark: # name is the plugin file path, it must be an absolute path!. The framework uses it to locate the module on disk and load it. A relative path is indeterminate because of the question "relative to whom"—is your current working directory the repository root? Or the location where the Web UI was started? Or some build output directory? As long as it is indeterminate, loading will succeed or fail at random. An absolute path eliminates this indeterminacy.
Here is a comparison table of the differences between these two fields:
| Field | Meaning | Example value | Affects file resolution | Common mistakes |
|---|---|---|---|---|
| id | The logical identifier of the insert entry, used for internal references within the configuration and for distinguishing entries in logs | hello | No | Duplicating the name of another entry, causing ambiguous references |
| name | The plugin file path; the framework loads the module based on this | /absolute/path/.../src/my-plugin.ts | Yes | Writing a relative path, which causes loading and resolution to fail |
There is also a related issue: this path points to a .ts source file, not a compiled artifact. In the example from the material, name explicitly points to scratch-plugin/src/my-plugin.ts, which indicates that this workflow expects the framework (or the development environment) to handle TypeScript. This means your earlier pnpm install and pnpm run build steps were not just for running the Web UI, but also for paving the way for this "directly load TS source files" development experience. If you skip the build step, even if the path is written correctly, you may still run into module resolution problems during the loading phase.
Another invisible killer in paths is spaces and special characters. The material uses the quoted form '/absolute/path/to/...', and the quotes are necessary: once your repository path contains spaces or characters that need escaping, not using quotes will cause the YAML parser to split the path.It is recommended to always wrap the path in single quotes, whether or not it contains spaces.At the same time, be careful not to mix Chinese characters into the path—some toolchains have problems handling non-ASCII paths, and this is exactly the point that Chinese developers most easily overlook (for example, placing the repository under a directory like "My Documents").
Use pwd to get the absolute path: avoid plugin loading failures caused by relative paths
Since name must be an absolute path, where is the most reliable place to get this absolute path? The answer is the most plain approach: run pwd in the repository root directory, and concatenate the output as-is into name. The operation order given in the material is to first "run pwd in the repository root directory to get the absolute path," and then "create scratch-plugin/cordis.yml" and fill it in.
Why emphasize "in the repository root directory"? Because what we need to concatenate is <repository root>/scratch-plugin/src/my-plugin.ts, and pwd outputs the absolute path of the current directory. Only when you are standing in the repository root directory is this prefix correct. If you run pwd in some other subdirectory, the resulting path will have one extra or one missing level.
The complete manual process can be written like this, making it easy for you to copy and execute:
# 1. Enter the repository root directory (adjust according to your own clone location)
cd /absolute/path/to/deepseek-harness
# 2. Get the absolute path of the repository root
pwd
# Example output: /absolute/path/to/deepseek-harness
# 3. Create the plugin directory and source directory (skip if already done in the previous article)
mkdir -p scratch-plugin/src
# 4. Confirm that the plugin source file is indeed in the expected location
ls -l scratch-plugin/src/my-plugin.ts
# 5. Write the overlay configuration, concatenating the output of pwd with the relative fragment into an absolute path
cat > scratch-plugin/cordis.yml <<'YAML'
# File path: scratch-plugin/cordis.yml
# This is a Web overlay, responsible only for inserting the local plugin
- insert:
- id: hello
# name is the plugin file path and must be an absolute path!
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
YAML
# 6. Verify that the concatenated path actually exists (a key step that can catch many low-level errors in advance)
test -f /absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts && echo "PATH OK" || echo "PATH BROKEN"The test -f ... && echo in step 6 is a self-check I strongly recommend keeping. It does something very simple: it confirms that the absolute path you wrote into name actually corresponds to a file on disk. Many cases of "plugin failed to load" never even reach the point where the framework can report an error—the path itself is wrong. Maybe the clone ended up somewhere different from what you expected, maybe the filename is misspelled (my-plugin written as myplugin), or maybe there's an extra directory level. Running this check takes two seconds and can save you half an hour of digging through logs.
There's also a mental trap related to "relative paths" worth mentioning here. Relative paths are tempting because they look more "portable"—you don't have to change them when switching machines. But in the context of plugin loading, portability is guaranteed by the project directory structure, not by relative paths: if you copy the entire scratch-plugin/ to another machine, the only thing you need to change is the repository-root prefix in name—one place. If you use a relative path instead, you're stuck with the eternal question of "relative to what?" Choosing an absolute path is the more robust engineering trade-off.
When you need to switch development environments frequently, you can extract this prefix into a small script to avoid manual edits:
#!/usr/bin/env bash
# File path: scratch-plugin/refresh-patch.sh
# Purpose: write the current repository root path into the name field of cordis.yml, avoiding errors from manually editing relative/absolute paths
set -euo pipefail
REPO_ROOT="$(pwd)"
PLUGIN_TS="${REPO_ROOT}/scratch-plugin/src/my-plugin.ts"
if [ ! -f "${PLUGIN_TS}" ]; then
echo "[refresh-patch] Plugin source file does not exist: ${PLUGIN_TS}" >&2
exit 1
fi
cat > scratch-plugin/cordis.yml <<YAML
# File path: scratch-plugin/cordis.yml
# This is a Web overlay, responsible only for inserting the local plugin
- insert:
- id: hello
# name is the plugin file path and must be an absolute path!
name: '${PLUGIN_TS}'
YAML
echo "[refresh-patch] Written: ${PLUGIN_TS}"
Note the set -euo pipefail and the file existence check in the script: these two things make path errors surface "before the configuration is written," rather than waiting until the framework starts. This habit of "shifting errors left" pays off enormously in plugin development, where configuration changes are frequent.
hello-plugin Load Verification: Observing the Plugin Lifecycle Through console.log
The configuration is written—how do you know it actually took effect? Through the logs. Look back at the plugin source from the previous post:
// File path: scratch-plugin/src/my-plugin.ts
import type { Context } from '@deepseek-ai/cordis'
// name is the plugin name, used to identify this plugin in logs and configuration
export const name = 'hello-plugin'
// apply is the plugin's entry point: the framework calls it when loading the plugin
export function apply(ctx: Context) {
// The required dependencies are already in place before apply runs (see post 9)
console.log('[hello-plugin] plugin loaded!')
}
There are two exports in this code worth chewing over repeatedly:
- export const name: the plugin name, used to identify this plugin in logs and configuration. It determines that the prefix you see in the logs is
[hello-plugin], and it also determines what name other configuration items use when they want to reference this plugin. - export function apply(ctx): the plugin's entry point. The framework calls it when loading the plugin and passes in ctx (the context object).
So the verification method is quite straightforward: when you see [hello-plugin] plugin loaded! in the logs, it means the framework really did load this file, really did call apply, and really did hand control over to your plugin. This single line of console.log is the first observable signal along the entire chain, and it is also the foundation for all the complex capabilities you will build later—if this line never appears, then registering events, registering tools, and registering LLM adapters later on are all out of the question.
Why do we say this one log line verifies three things at once? Let's break it down into a "lifecycle inspection":
- The file was located and loaded: if the name path is written wrong, the directory structure is incorrect, or the overlay didn't take effect, this module would never be evaluated, and console.log would naturally never execute.
- apply was called by the framework: even if the file is loaded, if the framework didn't call apply (for example, because the exported shape is wrong), the log wouldn't appear either. The log appearing = the framework recognized this module and treated it as a plugin.
- ctx has been passed in: apply's signature accepts ctx, and being able to execute through to console.log means the context object is already in place. This is the prerequisite for using ctx to register capabilities inside apply's body, and it also echoes that line in the source comment: "The required dependencies are already in place before apply runs."
Regarding ctx (Context), the definition given in the material is worth noting down separately: it is the context object the framework passes to every plugin, and it is both the entry point for registering capabilities and a record of all the resources a plugin has registered. That second half is especially important—it means ctx is not just a "toolbox" but also a ledger: whatever you register through it, it keeps track of. This is also why the action of "plugin loading" and "resource registration" are two sides of the same thing: once loading succeeds, ctx starts keeping the books for you. In this minimal hello-plugin example, we haven't called any of ctx's registration methods yet, so the ledger is empty—but the ledger itself is already in hand.
If you visualize the loading process as a sequence diagram, it looks roughly like this:
- You start the Web UI, passing
--patchpointing to the cordis.yml you wrote earlier; - The framework reads this overlay, encounters the insert directive and the absolute path specified by name;
- The framework loads the module at that path, identifying name and apply;
- The framework prepares ctx for this plugin and calls apply(ctx);
- The console.log inside apply executes, and you see
[hello-plugin] plugin loaded!; - From then on, ctx continuously records all resources registered by this plugin.
It is worth emphasizing the word "continuously" in step 6: ctx is not a one-off function parameter; it accompanies the plugin throughout its entire lifecycle. So the correct mental model is not "I used ctx once inside apply," but rather "I entrusted the plugin's entire life to ctx." Once you understand this, when you write more complex plugins later, you will naturally think: how will the things I register through ctx be unregistered in the future, how will they be discovered by other plugins, and how will they be traced in logs.
September 2026 Latest Practice: The scratch-plugin Convention as a Plugin Development Sandbox
Looking at the steps above as a whole, you will find they are not scattered tricks but a well-formed workflow. As of the current version, the combination of "scratch-plugin isolated experiment plugin + patch overlay loading" described in the materials has already become a fairly mature development convention in the community, and is worth fixing as the default approach. Its core idea is: new plugins should always first grow in an independent directory, be connected through an overlay, and only after verification be considered for merging into the formal structure.
Why does this convention work well? Because it separates "experiment" and "production" at the physical level:
- Risk isolation: No matter how much you mess around with things in scratch-plugin, it will not alter the framework's main configuration. If you break it, deleting the entire directory returns you to a clean state.
- Unified loading method: No matter how small or large the plugin, the integration method is always "write a cordis.yml + start with --patch," keeping the mental burden constant.
- Reproducible: The directory + overlay form a self-contained unit that can be copied to someone else and run, making it ideal for writing tutorials, minimal reproductions, and bug report attachments.
- Naming as documentation: The word scratch itself conveys the semantics of "testing ground, disposable." Seeing this directory name, anyone will expect that its contents are not long-term assets.
The corresponding standard operating rhythm can be fixed as a five-step cycle:
cdto the repository root, and usepwdto get the absolute path;- Create or modify a plugin file under
scratch-plugin/src/, exporting name and apply; - Update
scratch-plugin/cordis.yml, ensuring name points to the .ts file you just modified (absolute path); - Start with
--patch, and observe whether the corresponding loaded output appears in the logs; - After confirming everything is correct, continue adding capabilities (registering events, tools, LLM adapters through ctx).
There is one point that is easily underestimated in practice: insert only one plugin at a time. In the example from the source material, the insert contains only a single id: hello. When first learning, many people think, "Since insert supports arrays anyway, I'll just stuff all five experimental plugins in at once." This seems convenient early on, but it destroys the observability described in step 4 above: when five log lines appear simultaneously, you cannot determine which one corresponds to which file or which change introduced the problem. The correct approach is to let the insert array grow gradually along with your verification progress—add one today, confirm the logs are clean, then add another tomorrow.
Another convention worth emphasizing is using square brackets around the plugin name for all log prefixes, such as [hello-plugin] plugin loaded!. This is not a hard requirement, but once you start developing multiple plugins simultaneously, it becomes almost your only lifeline: amid hundreds of lines of interleaved output, the [hello-plugin] prefix lets you instantly filter out the lines belonging to your own plugin. I recommend enforcing this convention strictly at every log point, including key moments such as successful registration, failed registration, and event reception.
Finally, regarding the --patch parameter itself, there is one more piece of experience: write it into your startup script or npm script rather than typing it by hand every time. The problem with typing it by hand is that it is easy to forget—especially when you restart the framework after modifying cordis.yml. If you forget to include --patch, the framework will start normally and run normally, but your plugin will not be loaded at all, and then you will puzzle over "why is there no [hello-plugin] in the logs" for a long time. Fixing your startup command in place is the most effective way to guard against this kind of "silent failure."
Plugin Loading Failure Troubleshooting Checklist: Check Path, Parameters, and File Location
Even if you follow everything exactly, the first load may not succeed on the first try. The good news is that failures at this stage are highly concentrated in their causes and can be quickly pinpointed with a "three-check checklist." The so-called three checks are: check the path, check the parameters, and check the file location. Below, they are laid out in order of troubleshooting priority from highest to lowest.
Check one: whether name is an absolute path. This is the most frequent source of failure, and it is also the one the source material emphasized with an exclamation mark. The check is simple: open cordis.yml and see whether the value of name starts with / (on Unix-like systems). If it starts with . or .. or a directory name, then it is a relative path and must be changed. When changing it, remember to wrap it in quotes, and immediately self-check with the test -f from the previous section.
Check two: whether --patch was included at startup. If the path is completely correct but there is simply no output in the logs, the second thing to suspect is that the overlay was never read in at all. The source material clearly states that two things are used here: the patch overlay cordis.yml and the --patch startup parameter. Neither can be missing—if you only write the file but do not pass the parameter, the framework does not know to read it; if you only pass the parameter but have no file, the framework has nothing to read. How to check: look back at your startup command and confirm that --patch and the path it points to are both present.
Check three: whether cordis.yml is placed under scratch-plugin. In the directory structure given by the source material, cordis.yml and src are sibling nodes, both located under scratch-plugin. If your file is placed at the wrong level (for example, at the repository root, or mistakenly inside src), then even if the name path is correct and the parameter is included, the patch path you pass will not match the actual file location, and it will still fail. How to check: ls -l scratch-plugin/ and confirm that you see both src and cordis.yml.
Putting these three checks into a table makes them easier to compare:
| Check | Target | Expected State | Typical Symptom |
|---|---|---|---|
| Path | The value of name in cordis.yml | An absolute path starting with /, and the file actually exists | The plugin produces no log output at all |
| Arguments | Whether the startup command includes --patch and the correct path | --patch points to scratch-plugin/cordis.yml | The overlay does not take effect and the plugin is not inserted |
| File Location | The relative level of cordis.yml and src | Both sit side by side under scratch-plugin | The patch path cannot resolve to the file |
Beyond these three checks, there are a few "secondary suspects," ordered by frequency of occurrence:
- YAML indentation errors: Using Tab or inconsistent indentation levels, causing parsing to fail. The symptom is usually a configuration parsing error at startup, rather than the plugin silently failing to load.
- Incorrect export shape: Forgetting to export apply, or writing apply as an arrow function assigned to a variable without exporting it. No logs will appear either, because the framework cannot find the entry point.
- File paths containing spaces or special characters without quotes: YAML splits the path, turning name into a truncated string.
- Skipping the build step: If you only ran git clone and pnpm install without
pnpm run build, the environment itself may be incomplete, and loading TS source files will produce module resolution problems unrelated to the path. - Duplicate id: Two insert entries in the same cordis.yml use the same id, causing ambiguous references. This is especially important to watch for in multi-plugin scenarios.
The recommended order for troubleshooting is: first self-check the path (seconds) → then look at the startup command (seconds) → then confirm the file location (seconds) → only then dig through the full logs. The value of this order is that the first three steps are deterministic and do not require reading long logs, and they cover the vast majority of failures; leaving log reading for last is something you do only after ruling out the first three steps. Many people's troubleshooting habits are exactly the opposite—they immediately scroll through hundreds of lines of logs and end up drowning in irrelevant information.
There is also one piece of hard-earned personal experience: after changing the configuration, be sure to confirm that the framework has been restarted. cordis.yml is read at startup, so if you only hot-reload the code without restarting the framework, changes to the overlay will not take effect. This is especially easy to do during multi-window development—you change the yml in window A, and in window B you stare blankly at the logs of the old process.
Summary and Best Practices
At this point, the entire chain is closed-loop, from directory structure, insert syntax, field semantics, and how to obtain absolute paths, all the way to load verification and troubleshooting. Here is the whole article compressed into an executable checklist:
- Fix the directory structure as "one sandbox = one src + one cordis.yml": under scratch-plugin/, place
src/andcordis.ymlside by side. Do not drop the config into src, and do not pollute the repository root. - Write only insert in cordis.yml, keeping a single responsibility: this Web overlay is only responsible for inserting local plugins, without carrying any other changes, making it easy to delete and roll back as a whole.
- Add only one plugin to the insert array at a time: grow it gradually as verification progresses, ensuring that every loaded output in the logs maps to a clear file and change.
- Strictly distinguish id from name:
id(such as hello) is the logical identifier of the entry, used for config references and log differentiation;nameis the plugin file path and must use an absolute path. - Use pwd to get the absolute path, and self-check immediately: run
pwdin the repository root, assemble<repo root>/scratch-plugin/src/my-plugin.ts, then usetest -fto confirm the file actually exists, blocking errors before the config is written. - Wrap paths uniformly in single quotes: avoid spaces and special characters breaking the YAML path, and try to avoid directory paths containing Chinese characters.
- Use two spaces uniformly for YAML indentation: do not use Tab, and do not let formatting tools reorder this file.
- Always start with --patch, and hard-code it into the startup script: forgetting it is a typical "silent failure"; hard-coding the command completely avoids it.
- Use [hello-plugin] plugin loaded! as the signal of successful loading: this log line simultaneously proves the file was located and loaded, apply was called by the framework, and ctx was passed in; treat it as the foundation checkpoint for all subsequent capability development.
- Uniformly wrap the plugin name in square brackets for the log prefix: when developing multiple plugins in parallel, this is the only reliable means of quick filtering and locating.
- Keep in mind ctx's dual identity: it is both the entry point for registering capabilities and the record of all resources registered by the plugin; when later registering event listeners, tools, and LLM adapters through it, design it as "accompanying the entire lifecycle".
- Troubleshoot load failures in the order of three checks: first check whether name is an absolute path, second check whether startup includes --patch, third check whether cordis.yml is placed under scratch-plugin; after ruling out all three steps, then suspect YAML indentation, export shape, path quotes, build steps, and duplicate id in turn.
- Treat scratch-plugin as a disposable testing ground: let experimental plugins grow here first, and only after connecting through the overlay and verifying via logs consider merging them into the formal structure, keeping experimentation and production physically isolated.
Finally, let me emphasize the order one more time: first get the plugin loaded, then talk about what it can do for the framework. An empty plugin that can print "loaded" is better than a fully-featured complex plugin that simply won't load. Drill today's set of "absolute paths + patch override layer + log verification" into muscle memory, so that later, when you use ctx inside apply to register events, tools, and LLM adapters, you can focus on the actual business logic instead of wasting time on questions like "why isn't my plugin taking effect."