When doing plugin development on DeepSeek Harness, you can't get around two terms: scope isolation and the event system. The former addresses "how resources are divided," while the latter addresses "how messages get through." If your plugin group has two sets of Bash executors, two sets of logging configurations, and two sets of permission policies, they must be mutually invisible and each instantiated independently; and when you need a dozen plugins that don't know each other to collaborate at some extension point, you can't have them hard-code calls to one another. Cordis's answer is: use isolate for service isolation, use scope to manage visibility, and use the five dispatch modes emit / bail / serial / waterfall to cleanly divide the semantics of event communication. This article starts from a runnable minimal experiment directory scratch-plugin/cordis.yml and thoroughly explains both main threads at once—"how the same shell service splits into two instances" and "how events travel from the listener side to the trigger side"—so that in real projects you can both isolate plugin groups and wire them together when needed.

How the isolate field makes the same shell service split into two instances

First, look at an intuitive trap many people fall into the first time they use Cordis: thinking that a plugin group is just a "namespace" used to give plugins a nice-looking prefix. In fact, the real power of a plugin group is that it can become the boundary of a service instance. By default, a service is a singleton in the Cordis container—whoever requires it gets the same object. This is fine in most scenarios, but once it involves services that are stateful, have configuration differences, or occupy resources (such as a Bash executor with a timeout setting), sharing a singleton becomes a disaster: group-a wants to set the Bash timeout to 5 seconds, group-b wants 60 seconds. If they share the same instance, the later configuration overwrites the earlier one, and behavior becomes unpredictable.

The isolate field is used to break singleton semantics. It is written as key-value pairs, where the key is the service name and the value is a boolean, for example isolate: { shell: true }. When you declare isolate: { shell: true } on a group, you are actually telling Cordis: within the boundary of this group, the shell service does not use the global singleton, but is instantiated separately for this group. All plugins in the group, when injecting the shell service, get the one exclusive to this group; plugins outside the group still see their own copy, or the global one. This means the two groups can safely apply different configurations to their respective shell instances without interfering with each other.

The key to understanding this is to view "service" and "service instance" separately. A service is a contract (name, interface, semantics), while an instance is a runtime object (configuration, state, resource handle). Without isolate, the contract and the instance have a one-to-one global mapping; with isolate added, the contract becomes one-to-many, and the granularity of the instance sinks down to the plugin group. You can analogize it in your mind to static variables and instance variables in a programming language: without isolate it's like a global static singleton, and with isolate it's like each object holding its own member fields. This mental model is crucial for understanding the division of labor between scope and isolate later.

There is also a detail that is easy to overlook: isolate's isolation is directional. It does not make the isolated service private to all groups—it only makes the group that declared isolate hold an independent instance. If no other group declares isolation, they still share the default instance. So when designing a multi-group architecture, the question you should ask yourself is not "whether to isolate," but "which groups need independent instances and which groups can share." The usual approach is: services with configuration differences, state writes, or lifecycles that need independent management tend to be isolated; purely functional utility services and read-only metadata services can continue to be shared to save resources. This rule of thumb can help you avoid two extremes: either isolating everything, causing instance bloat and wasted memory; or isolating nothing, causing configurations to contaminate each other and debugging until you doubt life.

@deepseek-ai/cordis-plugin-group and group: true: the declarative way to group plugins

Now that we understand the semantics of isolate, the next thing to solve is "who the isolation is attached to." The answer is the @deepseek-ai/cordis-plugin-group plugin, which packages the isolation capability into a declarative configuration. Note that grouping is a prerequisite for isolation to take effect—you cannot simply add isolate to an ordinary plugin and expect it to split into multiple instances. isolate is a property of the group plugin; there must first be a group, and only then can the group declare isolation.

A group entry in cordis.yml looks like this, involving three key fields:

  • id: the group's unique identifier, such as group-a, group-b. It is not just a name; it is also the anchor for locating the group in logs, diagnostics, and configuration references. The ids of multiple groups must be unique, otherwise a conflict will be reported at the loading stage.
  • name: here it is fixed to '@deepseek-ai/cordis-plugin-group', indicating that this entry is loaded by the group plugin. In other words, name determines "which kind of loader is used to instantiate this entry."
  • group: a boolean value that must be true. This field is an explicit switch that tells Harness this entry is not an ordinary plugin but a "group container." Many beginners forget to write this line, and as a result the child plugins in the config are treated as flat entries, so isolation naturally does not take effect.

Among these three points, the most easily underestimated is the explicitness of group: true. Why not make it automatic inference? Because Cordis's configuration style leans toward explicit declaration—better to write one extra line than to do magic inference. The benefit of explicitness is that anyone reading the configuration file can tell at a glance whether "this is an ordinary plugin" or "this is a group," and the toolchain can also precisely identify boundaries when doing static analysis and dependency graph rendering. Think of group: true as a bracket notation: it delimits the scope of isolation. Inside the brackets is the group's internal world; outside the brackets is the external world.

So where is isolate written? It is written at the group entry's own level, on par with id, name, and group, rather than stuffed into the configuration of some child plugin. This is a common level mistake: some people instinctively write isolate into the child plugin's config because they think "isolation is aimed at this child plugin's service." But semantically it is the opposite—isolation is a property of the group, and the group applies for it uniformly on behalf of its members. There may be multiple plugins inside a group, and they may all consume the shell service; once the group declares isolate, all consumption of shell within the entire group points to this group's instance. So the correct notion of level is: isolate belongs to the "container," not to the "members."

One more thing to keep in mind: the name field must be written exactly the same as the package name, including the scope prefix. If you have a local monorepo or mirror, the package name may differ, and in that case you need to make sure both the group's name and the child plugin's name can be resolved by the resolver. When resolution fails, the usual behavior is a direct error thrown during the loading phase rather than a silent fallback, so this kind of problem is generally discovered quickly.

timeoutMs from 5000 to 60000: using configuration differences to verify whether isolation is actually in effect

Once the mechanism is explained, verification comes next. The thing isolation fears most is "it looks configured but isn't actually isolated," and the most plain yet most reliable way to verify isolation is to create an observable configuration difference, then observe whether behavior follows each configuration. The example in the source material is very typical: dsh-bash-local in group-a is configured with timeoutMs: 5000, while the plugin with the same name in group-b is configured with timeoutMs: 60000. Same service, same plugin name, only the timeout value differs.

The key here is a construction of "same name, different configuration." If isolation is in effect, a Bash call initiated in group-a will time out at around 5 seconds, while a call initiated in group-b can run for 60 seconds; the timeout behavior of the two groups is independent of each other, and changing one will not affect the other. If isolation is not in effect (for example, you forgot to write isolate, or wrote isolate at the wrong level), the two groups will share the same shell instance, so the later-loaded configuration will override the earlier-loaded one, and the behavior of the two groups will converge to the same value. This convergence phenomenon is the fingerprint of failed isolation.

To turn this verification into a reproducible experiment, it is recommended to proceed in the following order:

  1. Prepare a command that clearly takes longer than 5 seconds but is far less than 60 seconds, such as a script that sleeps for 10 seconds. The choice of duration matters: it must be greater than 5 seconds so that group-a triggers a timeout, and less than 60 seconds so that group-b can complete successfully. 10 seconds falls right in the middle of the two thresholds, giving the highest degree of discrimination.
  2. Initiate this command in the plugin of group-a, and observe that the result is a timeout failure. Record the actual elapsed time; it should be around 5 seconds rather than 10 seconds.
  3. Initiate the same command in the plugin of group-b, and observe that it completes successfully, with an actual elapsed time of about 10 seconds.
  4. Run a control experiment: change group-b's timeoutMs to 3000 (another difference that is less than 5 seconds), restart, and observe again. If group-b starts timing out while group-a is unaffected, this further confirms the isolation.
  5. Run a counter-proof experiment: temporarily remove the isolate declaration from one group, restart, and observe whether the behavior of the two groups begins to affect each other. This step helps you build muscle memory for "what exactly isolation changes."

There is one engineering detail worth emphasizing in this step: during verification, make sure the plugins of the two groups are indeed loaded within their respective groups. If you put both plugins in the same group, then regardless of whether isolate is present, they will share the same instance, and the experimental conclusion will mislead you. Therefore, the directory structure and configuration hierarchy must align with the experimental goal, which is also what the next subsection will elaborate on.

The nested structure of the config array in cordis.yml: how plugins within a group are instantiated layer by layer

The thing that requires the most care in Cordis configuration is the hierarchy, because it uses a nested config array rather than a flat key-value table. In a group's entry, config is an array, and each element of the array represents a member within the group; the member itself can also have name and config, and its own config then describes that member's own parameters. This forms a recursive tree structure: group → member → member parameters.

Let's align on the semantics first to avoid confusion:

LevelFieldSemanticsCommon Mistakes
Group entryid / name / groupDeclares that this is a group container, its unique identifier and loaderOmitting group: true, causing the group to degrade into a regular plugin
Group entryisolateDeclares which services this group should instantiate independentlyMistakenly writing it into a member's config, so isolation does not take effect
Group entryconfig (array)The list of members this group should load, instantiated in orderWriting it as an object instead of an array, causing parsing to fail
Member entrynameThe package name or relative path of the member pluginWriting the path incorrectly, causing an error at the loading stage
Member entryconfigThe parameters for that member (such as timeoutMs)Writing service-level configuration at this level, causing semantic misalignment

The loading order is "depth-first, in array order." When a group is loaded, Cordis instantiates members in the order of the elements in the config array: it first loads @deepseek-ai/dsh-bash-local and applies its timeoutMs, then loads ./src/plugin-a.ts. This order matters because later members typically inject the services provided by earlier members. If the order is reversed, a plugin loaded later may not be able to obtain the shell service during initialization, resulting in errors like "injection is empty" or "service not registered." So there is a practical principle: put plugins that provide services first, and plugins that consume services later. This is consistent with the approach of many frameworks, but Cordis turns it into a pure configuration convention with no extra syntactic sugar, so it is all the more important that you follow it consciously.

Let me emphasize once more the separation between isolate and the config level, as this is the easiest pitfall to fall into. Look at this structure: a group entry's isolate is a container-level declaration, determining "whether the shell inside this group is an independent instance"; while the timeoutMs in a member entry's config is a member-level parameter, determining "the specific behavior of this shell instance." Only when the two work together is it complete: isolate is responsible for splitting the instance apart, and the member config is responsible for filling in parameters for the split instance. If you only write isolate but do not write different parameters in the members, then although the two instances are independent of each other, their configurations are identical, and no difference can be observed experimentally, which may lead people to mistakenly believe that isolation did not take effect; if you only write different parameters in the members but do not write isolate, then the two groups share an instance, and the parameters written later overwrite those written earlier, so again no difference can be observed experimentally. Therefore, verifying isolation requires both a correct isolate declaration and differentiated member parameters, neither of which can be missing.

So what happens if the nesting level is written incorrectly? There are usually two manifestations: first, a parsing error is thrown directly at the loading stage, for example if the config array is written as an object (using curly braces instead of square brackets), and the parser cannot treat it as a sequence of members; second, loading succeeds but the semantics are misaligned, for example if timeoutMs is placed in the group entry's own config, so that it is neither a group container parameter nor a parameter of any member, and is silently ignored, and the phenomenon you see is "I clearly configured 60 seconds but it has no effect." The second is more insidious because it does not report an error. It is recommended to develop a habit: after configuring, check which level each value falls into, and ask yourself "who is this value for?" If the answer is "for a specific member," it should be in that member's config; if the answer is "for this group as a whole," then it belongs at the group entry level.

scratch-plugin/cordis.yml: A Minimal Runnable Service Isolation Experiment Directory

No amount of theory beats a directory that actually runs. The path given in the material is scratch-plugin/cordis.yml, and the naming itself is deliberate: scratch implies "draft, disposable", and using it for isolation experiments means this is just a small verification project, not a production directory. Keeping experiments and production separate is a good thing, because it lets you boldly delete configs, tweak parameters, and test counterexamples without worrying about polluting the real environment.

A minimal runnable isolation experiment directory is best organized like this:

scratch-plugin/
├── cordis.yml          # Main config, defines group-a and group-b
└── src/
    ├── plugin-a.ts     # Member plugin of group-a, consumes shell
    └── plugin-b.ts     # Member plugin of group-b, consumes shell

Correspondingly, the contents of cordis.yml are the two sets of configs we have repeatedly broken down earlier: group-a declares isolate: { shell: true }, and its members include @deepseek-ai/dsh-bash-local (timeoutMs: 5000) and ./src/plugin-a.ts; group-b likewise declares isolate: { shell: true }, and its members include the same-named plugin but with timeoutMs: 60000, plus ./src/plugin-b.ts. Pasting this config in full looks like this:

# File path: scratch-plugin/cordis.yml
# Defines two plugin groups group-a and group-b, each isolating its own shell service
- id: group-a
  name: '@deepseek-ai/cordis-plugin-group'
  group: true
  isolate:
    shell: true   # Let the shell service within this group be instantiated independently
  config:
    - name: '@deepseek-ai/dsh-bash-local'
      config:
        timeoutMs: 5000
    - name: './src/plugin-a.ts'
- id: group-b
  name: '@deepseek-ai/cordis-plugin-group'
  group: true
  isolate:
    shell: true
  config:
    - name: '@deepseek-ai/dsh-bash-local'
      config:
        timeoutMs: 60000
    - name: './src/plugin-b.ts'

Regarding relative paths, there is an easily overlooked detail: the ./ in ./src/plugin-a.ts is resolved relative to the config file (usually the project root or the directory containing the config), and the resolution base may differ slightly across toolchains. The safe approach is to first load only one plugin with a minimal config, confirm the path can be resolved, then add the second group. If path resolution goes wrong, the error is usually something like "module not found", which is fairly straightforward. Another practical tip: in the early stage of the experiment, you can write only group-a and not group-b, first ensuring that isolate and timeoutMs take effect within a single group, then add the second group to observe the isolation effect. This lets you separately pinpoint two kinds of problems—"config hierarchy errors" and "isolation not taking effect"—rather than turning on both groups from the start, where once something goes wrong it is hard to tell which layer is at fault.

Once it's running, you can also print some diagnostic information inside the plugin, for example, printing some identifier or timeout value of the current shell instance when plugin-a and plugin-b are initialized. Note here that the material does not provide a specific instance ID interface, so do not assume that some fixed instance identifier field exists; the more reliable verification method is still the behavioral verification discussed in the previous section (using a 10-second command to hit the two thresholds of 5 seconds / 60 seconds), rather than reading some internal ID. Behavioral verification does not depend on the internal implementation and is more reliable in the long run.

The division of labor between scope and isolate: who determines visibility, who determines the number of instances

This is the pair of concepts in the entire article that most needs to be clarified. Many developers conflate scope and isolate when troubleshooting plugin issues, and as a result their direction is completely wrong. Their responsibilities are orthogonal: scope governs visibility, isolate governs the number of instances.

Let's talk about scope first. Scope answers the question "who can see this service." It is a visibility barrier: a service registered within a certain scope can be injected within that scope; plugins outside the scope looking at it are like looking at something that does not exist. Scopes can be hierarchical, forming a tree structure, and child scopes can usually access services of the parent scope, but not the other way around. The value of scope lies in permissions and encapsulation: if you want a certain tool to be open only to a certain subsystem, register it in the corresponding scope; if you want a certain global capability to be open to all plugins, register it on the root scope.

Now let's talk about isolate. Isolate answers the question "how many instances does this service have." It does not change visibility; what it changes is the granularity of instantiation. A service may be visible to all groups (visibility is fine), but each group gets its own independent instance (isolation takes effect). Conversely, a service may also have only one global instance (no isolation), but be visible only to some plugins through scope. The two mechanisms can be freely combined, forming four cases:

Configuration combinationVisibilityNumber of instancesTypical use
No isolate + broad scopeGlobally visibleSingletonStateless tools, read-only metadata
No isolate + narrow scopeVisible only to a specific scopeSingletonCapabilities exclusive to a subsystem but shared internally
isolate + broad scopeVisible to all groupsOne per groupStateful services with configuration differences (such as shell)
isolate + narrow scopeVisible only to a specific scopeOne within that scopeSensitive services that have both permission constraints and independent configuration needs

The key to understanding this table is: visibility and the number of instances are two independent dimensions, and you should not use one mechanism to solve the problem of the other mechanism. If you want a certain service not to be seen by other groups, simply adding isolate is useless—isolate is only responsible for splitting instances, not for blocking the line of sight, and other groups can still see it and get their own instance. If you want a certain service to have different configurations in each group, simply adjusting scope is also useless—scope is only responsible for blocking the line of sight, and does not care whether instances are split. So when troubleshooting, first ask yourself: what I want is "cannot see it" or "one copy each"? For the former, use scope; for the latter, use isolate; if you want both, configure both.

Another common misconception is that "after isolate, instances cannot communicate with each other." This is not the case. What is isolated is the instances, not the ability to communicate. Plugins in two groups can still communicate with each other through the event system (the subject of the next subsection); they simply get their own service instances. Isolation solves resource ownership, not coupling; decoupling is left to events. Keep these two things separate, and your architectural thinking will be much clearer.

ctx.on and ctx.emit: The Two Ends of the Event System and the Timing of Callback Registration

Now that isolation is covered, let's switch to communication. Cordis's event system has only two ends: the listener side, ctx.on, and the trigger side, ctx.emit. These two ends form the core mechanism for loosely coupled communication between Cordis plugins. Harness makes extensive use of events to implement extensible extension points—in other words, many of the places in the framework where "you can insert your own logic" are essentially events.

The listener side registers a callback, and the trigger side broadcasts to all listeners. The basic form is as follows:

// Listen for an event: register a callback
ctx.on('event-name', (payload) => {
  // handle the event
})

// Trigger an event: broadcast to all listeners
ctx.emit('event-name', payload)

These two lines look simple, but there are several engineering questions you must think through. The first is registration timing. ctx.on must execute before the event is triggered; otherwise, the callback you registered will not receive that trigger. This means the listener side usually needs to be placed in the plugin initialization phase (such as in the plugin's setup or apply logic), rather than in some lazy branch that "registers only when used." If your plugin depends on a certain event but registers it in a place that only executes on first invocation, then you will inevitably miss the first event trigger. This is a very insidious type of bug, because it may not appear in a single-flow test and only surfaces in concurrent or timing-sensitive scenarios.

The second question is lifecycle and cleanup. Listeners are not free; once registered, you must consider when to remove them. If a plugin is unloaded, or if a certain context is destroyed, but the listener is still attached to the event, then the callback may be invoked on an object that is no longer valid, or the in-memory listener list may grow longer and longer. Cordis provides context-based event registration capability—the listener is bound to the context that registered it, and when the context is destroyed, the listener becomes invalid along with it. This is an important architectural convenience: understand ctx.on as "listening during the lifetime of a certain context," rather than "stuffing a callback into a global array." The former cleans up automatically; the latter requires you to manage it manually. When writing code, prefer context-bound registration, which can save you a great deal of cleanup logic.

The third question is the payload convention. The article title for events only gives the formal parameter (payload) and does not specify the structure of payload. This is intentional: Cordis's event system does not enforce the type of payload; the payload of a specific event is determined by the event's definer. As a plugin developer, whichever event you consume, you must read the payload according to the convention of the event's definer; when you define your own extension points, you must also clearly document the payload fields. The engineering recommendation is: use extensible objects for payloads rather than positional parameters, so that adding fields in the future will not break existing listeners. At the same time, the payload should contain enough contextual information (such as source identifier, target, configuration) so that listeners do not have to go back and look up global state. This is especially important in multi-group isolation scenarios—because after isolation, each group may not be able to see the other groups' global state, and payload becomes almost the only reliable information channel.

There is another question that echoes isolation: are events themselves isolated? This is something many people instinctively ask. Here we need to distinguish carefully: the material only explains the APIs and dispatch modes at both ends of events, and does not state whether events are automatically isolated between isolate groups. Therefore, a sound engineering design is: do not assume that events are automatically isolated by group, and do not assume that all groups can necessarily receive each other's events. If your plugin group requires strict event isolation, you should include a group identifier in the event name or payload and filter on the listener side; if you want cross-group communication, use an explicit public event name. Treat "whether events are visible across groups" as a decision point you need to design explicitly, rather than silently relying on some default behavior. That way, no matter how the framework evolves later, your code remains stable.

A full view of the five dispatch modes: the semantics of emit / bail / serial / waterfall

Once you have learned both ends, on and emit, the next thing to face is the real essence of the Cordis event system: dispatch modes. The material's title names five: emit, bail, serial, waterfall (strictly speaking, the title contains these four words plus "and five kinds"; judging from the naming pattern of these four, they represent several basic semantics for return value handling and interruption behavior). Why are multiple modes needed? Because "one event subscribed to by multiple listeners" has completely different expected behavior in different scenarios: sometimes what you want is notification-style broadcasting, where no one affects anyone else; sometimes what you want is "stop as soon as the first person gives a valid result"; sometimes what you want is sequential processing that can accumulate results; and sometimes what you want is for each listener to be able to rewrite the input and pass it to the next. Making these semantics into different dispatch modes, rather than having each event define its own conventions, makes the collaboration rules of the plugin ecosystem unified and predictable.

Below, a comparison table lays out the return value handling and interruption behavior of each mode. The table emphasizes behavior "when multiple listeners exist at the same time," because that is where the real differences between modes play out:

ModeReturn value handlingInterruption behaviorTypical semanticsUse cases
emitBroadcast-style; listener return values are usually not aggregatedDoes not interrupt because of a single listener's resultNotification, broadcastLogging, status reporting
bailReturns as soon as the first meaningful return value is obtainedShort-circuits after any listener provides a resultRace, short-circuitObtaining the first usable processing result
serialExecutes in order and collects each listener's return valueSequential execution, usually processed one by oneSerial accumulationWhen multiple participants need to contribute results in turn
waterfallThe previous listener's output becomes the next one's inputPasses along the chain and rewrites step by stepPipeline, transformation chainContent rewriting, configuration override, middleware-style processing

Expanding one by one: emit is the most basic mode, with the semantics of "this happened to me; whoever cares can speak up." Its core expectation is that listeners do not interfere with each other, so it makes no strong contract about return values and will not interrupt other listeners because of one listener's result. Typical uses of this mode are logging, metrics, and status broadcasting—their common trait is that "multiple consumers are independent of one another," and an error in one listener should not prevent other listeners from receiving the notification.

The semantics of bail are "whoever produces a result first wins, and the rest don't run". This suits scenarios where "multiple plugins can all handle the same thing, but only one result is needed": for example, multiple strategy plugins can all offer suggestions for a decision, and taking the first valid one is enough; subsequent listeners don't need to waste computation since someone has already given an answer. In bail mode, pay attention to the execution order of listeners—if the order is nondeterministic, then "the first" is nondeterministic, which can lead to behavioral drift under high concurrency. So when using bail, either ensure the listener order is controllable, or ensure that the multiple results are interchangeable and any one will do.

The semantics of serial are "line up, one by one, and collect the results". Its difference from emit lies in whether results are aggregated, and its difference from bail lies in whether it short-circuits. When an extension point needs multiple plugins to contribute content in sequence and all contributions must be retained, serial is the natural choice. For example, generating a report where multiple plugins each append a section, and finally all sections are concatenated. In serial mode, pay attention to whether the failure of a single listener affects the rest: if the convention is "one failure aborts the entire chain," then the failing plugin drags down the other contributors; if the convention is "each is independent, and a failure is only logged without affecting others," then the report may be missing a chapter but still usable. This needs to be made explicit when designing the extension point, otherwise it becomes sporadic data loss in production.

waterfall is the most powerful and also the most easily misused mode. Its semantics are "input flows along the listener chain, each listener can see the previous one's output, and can rewrite it before passing it to the next," very much like middleware or a pipeline. It suits content-rewriting scenarios: for example, one plugin fills in default values for the config, the next plugin performs another override based on the filled-in values, and the next performs final validation. The order of waterfall is extremely important, because the input has a direction of flow, and if the order changes, the result changes. When using it, there are two disciplines: first, each listener should be clear about whether it modifies the payload; if it doesn't modify it, pass it through as-is, and don't return an incomplete object that screws over the plugins after it; second, it should tolerate changes from upstream, because your input may have been rewritten by any upstream listener, so don't assume a field definitely exists.

So what's the approach to choosing a mode? Ask yourself two questions: First, should the results of multiple listeners be aggregated? If not, use emit; if so, use serial. Second, is short-circuiting allowed? If allowed and you only need the first result, use bail; if you need to transform one by one, with output as input, use waterfall. Answer these two questions clearly, and the mode is basically decided. Finally, let me emphasize a cross-mode common issue: regardless of the mode, exception handling for listeners must have a unified convention. The material doesn't specify what happens to other listeners when one listener throws an error, so in engineering you should do proper try/catch inside your own listeners, especially those instrumentation-style callbacks that are invoked concurrently—an uncaught exception may affect the entire call stack when emit broadcasts. Treating "listeners should not throw unhandled exceptions" as a team discipline can save you a lot of inexplicable incidents.

At this point, the two main threads of isolation and communication have each been laid out: use isolate to make the shell service its own instance in each group, use group: true to delineate the boundaries of groups, use the difference in timeoutMs to verify that isolation really takes effect, and use the division of labor between scope and isolate to clarify the two orthogonal dimensions of visibility and instance count; at the same time, use ctx.on / ctx.emit to set up the two ends of communication, and use the comparison of emit / bail / serial / waterfall to choose the correct dispatch semantics. In the next section, we'll put these mechanisms into more complex real-world collaboration scenarios, discussing how multiple isolation groups can safely collaborate with each other through events, and how configuration and code can work together to avoid isolation failure and event storms.

In the previous section, we took apart the isolate and group combo and explained it thoroughly, clarifying the main thread of "same service, multiple instances, visible by group." In this section, we shift our perspective to another track: the channel that plugins actually use to talk to each other—the event system. More importantly, we need to answer a question that is easy to overlook: once you have applied scope isolation, will event broadcasting also be shut outside the wall?

How waterfall payloads flow: how one listener rewrites the next listener's input

waterfall is the most "pipeline-like" of the five event dispatch modes. Its core rule is just one: the return value of each listener is passed on as the input payload for the next listener. If you do not return anything (return undefined), then the next listener still receives the payload passed down from upstream, and the chain does not break.

The engineering value of this mechanism is that it breaks "processing" into pluggable segments. For example, when a request enters the system, the first listener fills in the default timeout, the second listener injects the auth header, the third listener performs parameter validation, and the fourth listener records timing—each link only cares about its own segment and does not need to know who comes before or after. This is almost isomorphic to the middleware model, except that the waterfall chain is dynamically assembled by event name rather than hard-coded in an array.

One detail to note: the waterfall payload is "replaced link by link," not "merged link by link." In other words, the object returned by the second listener completely replaces the payload it received, rather than being appended to it. This means that if you return only the one field that changed, the upstream fields are lost. So in practice, when writing listeners, the safe approach is to spread the input first and then override the fields you care about, for example returning {...payload, timeoutMs: 8000} instead of returning only { timeouts: 8000 }. The root cause of many bugs where "later listeners cannot see earlier fields" lies exactly here.

Another pitfall is asynchrony. If a listener returns a Promise, then the chain will continue passing values only after the Promise resolves. This means that one slow listener can hold up the entire waterfall. For timeout-sensitive scenarios (such as a shell invocation path with timeoutMs: 5000), make sure the listeners on the chain are lightweight and do no blocking IO, or move the heavy work to a side path of emit.

The following code can be pasted directly into a TypeScript plugin to demonstrate a typical waterfall processing chain, including payload inheritance and order observation:

// File path: scratch-plugin/src/waterfall-demo.ts
import type { Context } from '@deepseek-ai/cordis'

export const name = 'waterfall-demo'

export function apply(ctx: Context) {
  // First link: fill in the default timeout, and be careful to preserve the other fields
  ctx.on('harness:command:before-run', async (payload) => {
    console.log('[waterfall #1] received payload:', JSON.stringify(payload))
    return { ...payload, timeoutMs: payload.timeoutMs ?? 5000 }
  })

  // Second link: inject the origin marker, also doing a shallow-copy merge
  ctx.on('harness:command:before-run', (payload) => {
    console.log('[waterfall #2] previous link result:', JSON.stringify(payload))
    return { ...payload, origin: 'plugin-waterfall-demo' }
  })

  // Third link: observation only, no return, payload passes through unchanged
  ctx.on('harness:command:before-run', (payload) => {
    console.log('[waterfall #3] final payload:', JSON.stringify(payload))
    // no return — the chain continues to use the return value from #2
  })
}

Once it runs, you will see the logs printed strictly in the order #1 → #2 → #3, and #3 can see both the timeoutMs and origin fields at the same time. This is the "relay" form of waterfall: the return value is the baton handed off to the next leg.

The short-circuit semantics of bail: how the first non-empty return terminates subsequent listeners

The semantics of bail can be summed up in one sentence: the first listener that returns a "non-empty value" directly terminates the entire dispatch chain, and subsequent listeners no longer receive the event. Here, "non-empty value" refers to any return value other than undefined and null—note that returning false, 0, or the empty string '' may all be treated as valid return values and trigger the short circuit. The exact determination depends on your version, so do not rely on these boundary values to express the intent of "not intercepting" when writing code.

It becomes clearer from the perspective of state transitions: the entire chain has only two states—continue propagation and terminated. The event starts in the continue-propagation state, and each listener it passes through makes one determination: is the return value non-empty? If non-empty, it switches to the terminated state and all subsequent listeners are skipped; if empty, it remains in the continue-propagation state. Once it enters the terminated state, there is no way back. Even if a later listener could return a more suitable result, it has no chance to execute.

This characteristic imposes an implicit requirement on the order of extension points. Because "who comes first and who comes later" directly determines "who can veto whom," the listener registration order of bail-type extension points must be an explicit contract rather than random. There are two engineering approaches: first, agree on a priority field (such as priority) explicitly declared by the registrant; second, converge mutually exclusive decision logic into the same listener to reduce cross-plugin veto competition. The most dangerous situation is this: two plugins both think they are the sole decision-maker, and as a result the one registered later can never seize the short-circuit opportunity, manifesting as "I clearly returned an interception value, but the system still continued running."

bail is especially suitable for three things: permission interception, direct return on cache hit, and pre-check for circuit breaking. Their common trait is—a hit is final, and no subsequent step needs to process it further.

serial and parallel triggering: the impact of listener execution order on side effects

The key characteristic of serial is serial execution: listeners execute one after another, and only after the previous one completes (including resolution of its asynchronous part) does the next one get its turn. This brings a very important guarantee—the execution order is deterministic, so when multiple listeners all need to write to the same external state, concurrent overwrites will not occur.

In contrast is the broadcast behavior of emit: it disperses the event to all listeners at the same time, which is a "notification" semantics rather than a "processing" semantics. emit does not care about return values, nor does it promise an ordering relationship among listeners (the specific scheduling details depend on the runtime implementation). Therefore, any scenario that requires "ordering, return values, and serial side effects" should not be patched together with emit.

There is a very easy engineering pitfall to fall into here: on a short-timeout path such as timeoutMs: 5000, if you broadcast with emit and some listener secretly makes a remote call, then because emit does not wait, the caller may already have moved on to the next step before the remote result comes back. The result is "the log clearly shows it executed, but the state was not persisted." This kind of problem is extremely time-consuming to troubleshoot, so the semantics must be thought through clearly at the event selection stage.

To see the differences among the four modes (including waterfall and bail) side by side, the table below can serve directly as a selection reference:

ModeReturn value handlingShort-circuitsExecution orderTypical use
emitIgnores return valuesNoBroadcast, no ordering guaranteeNotifications, logging, analytics
serialGenerally ignored, awaited in orderNoStrictly serialOrdered side-effect initialization, registration
bailFirst non-empty takes effectYes, and irreversibleSerial up to the short-circuit pointPermission interception, cache hits, circuit breaking
waterfallUsed as input to the next stageNoStrictly serialPayload processing, middleware-style handling

Pasting this table into your team documentation can save a great deal of bickering over "which kind of event should this extension point use."

Why Harness extension points are built on events: the trade-offs of loosely coupled communication

The fundamental reason Harness builds a large number of extension points on top of the event mechanism is that plugins are unaware of one another. Plugin A does not need to import Plugin B, nor does it need to know B's class names, method names, or constructor parameters. A is only responsible for attaching a callback to a certain event name, and B is only responsible for triggering that name at the appropriate time. The contract shared by both parties is reduced to a string (the event name) plus a payload convention, which is the lowest-coupling form of collaboration.

The benefits of this design are fully demonstrated in extensibility: adding a new plugin requires no changes to any existing code; as long as it listens to the right event name, it is automatically woven into the flow. Conversely, removing a plugin will not cause compile errors in the caller—at worst, some event has one fewer listener, and the behavior degrades to "no extra processing" rather than crashing. For an Agent framework that needs to evolve over the long term, this property of "can be added or removed without blowing up the main path" is extremely valuable.

But the cost is also clear: debugging costs rise significantly. Because the call relationships do not appear explicitly in the code, you cannot find the trigger point of an event by "jump to definition"; you can only rely on a global search for the event name string. When there is a subtle difference in the spelling of an event name, or the payload field names do not match, the problem will not surface at startup; instead, it manifests as some feature "silently not taking effect." The basic usage mentioned earlier, ctx.on('event-name', ...) and ctx.emit('event-name', payload), is precisely where naming conventions are most needed.

In practice, there are three countermeasures: first, establish a unified prefix for event names (for example, hierarchical naming like harness:command:before-run) to avoid collisions; second, define commonly used payload structures as shared type files so that TypeScript catches type mismatches for you at compile time; third, add a runtime log of "listener count" at key extension points, alerting when it exceeds the expected upper limit, to prevent some change from accidentally registering twice.

The Intersection of Isolation and Events: Will Cross-Group Events Be Blocked by Isolation?

This is the most easily misunderstood part of the entire article. Many people assume that once you use isolate to split the shell service into two instances, event broadcasting will also be separated accordingly—but the truth is not that simple. What isolate isolates is the service instance; it determines whether "different plugin groups get the same object or their own copies." The visible scope of event dispatch, on the other hand, depends on which scope the event itself is attached to.

In other words, isolation and events are two orthogonal axes. Splitting the shell into two only affects the instance that the ctx.shell reference points to; but if you call ctx.emit('some-event', payload) inside group-a, whether that broadcast reaches group-b's listeners depends on whether the event channel is bound to the root scope or to a group-internal scope. If it's bound to the root, it's visible across groups; if it's bound within a group, then it only circulates among the plugins in that group.

This leads to a typical troubleshooting scenario: a plugin in group-a emits an event, expecting a plugin in group-b to receive it and respond, but group-b never reacts. At this point, your first instinct should not be "bail short-circuited the event," but rather to suspect the scope boundary. The batch drill configuration below can help you quickly reproduce this phenomenon—two groups each isolate their own shell, but the event channel remains at its default:

# File path: scratch-plugin/cordis.yml
# Define two plugin groups, group-a and group-b, each isolating its own shell service
- id: group-a
  name: '@deepseek-ai/cordis-plugin-group'
  group: true
  isolate:
    shell: true          # Let the shell service within this group be instantiated independently
  config:
    - name: '@deepseek-ai/dsh-bash-local'
      config:
        timeoutMs: 5000  # group-a's Bash timeout is 5 seconds
    - name: './src/plugin-a.ts'

- id: group-b
  name: '@deepseek-ai/cordis-plugin-group'
  group: true
  isolate:
    shell: true
  config:
    - name: '@deepseek-ai/dsh-bash-local'
      config:
        timeoutMs: 60000 # group-b's Bash timeout is 60 seconds
    - name: './src/plugin-b.ts'

Note the difference in timeoutMs between the two groups: group-a is 5000, group-b is 60000. Once isolation takes effect, no matter how a plugin in group-a calls the shell, it gets the instance with the 5-second timeout and will never be contaminated by group-b's 60-second configuration. This is exactly the problem isolation is meant to solve—same service, different instances, different configurations.

As for events, if you want events to also be strictly confined within a group, you must explicitly register listeners and triggers on the group's scope rather than operating on the root ctx. The core question for judgment is: Where does my ctx come from? The ctx that a plugin's apply receives within a group naturally carries the group's scope, and listeners registered with it fall within that group; if you register using the root ctx or a reference passed across layers, it will end up outside the group.

Troubleshooting checklist: check these five points first when isolation isn't working

When you find that "the config says isolate, but the two groups still seem to be sharing the same service instance," don't blindly change the config. Go through the following path item by item, and you'll cover the vast majority of failure scenarios:

  1. Check the group declaration: Confirm that the entry carrying the isolation actually has group: true. For an ordinary plugin entry without group semantics, the isolate field has no carrier, so writing it is pointless.
  2. Check the isolate field: Confirm that the isolated service name is written under isolate and that its spelling matches the service registration name. In the material, the form is isolate: { shell: true }, where the key corresponds to the service identifier and the value is a boolean switch.
  3. Check the config hierarchy: Pay attention to the hierarchical relationship between the isolation declaration and the plugin configuration. Service configuration (such as timeoutMs) must go under the plugin entry it belongs to, not be flattened to the top level of the group; otherwise it will be silently ignored as an unknown field.
  4. Check the instance namespace: Confirm that the two instances really are two independent objects, rather than the same object referenced by two groups. You can verify this by printing instance characteristics in the plugin apply (for example, an auto-incrementing id or a config snapshot).
  5. Check scope ownership: Confirm that the event listener/trigger you want to isolate is attached to the ctx inside the group, not to the root ctx. Whether events are visible across groups is determined by this step and has nothing to do with the isolate field.

Codifying this checklist into the team's troubleshooting manual can reduce the average time to locate issues like "isolation failure" to a very short duration.

A September 2026 perspective: the latest practice trends in Harness plugin isolation and event dispatch

Looking back from September 2026, Harness has already formed some fairly stable practical consensus on configuration governance for multiple groups and multiple instances. The first is refinement of isolation granularity: in the early days, people were used to turning on isolate for the entire service directory in one go, but now the tendency is to isolate only those services that truly need differentiated configuration, such as shell (different timeouts), network egress (different proxies), and cache (different capacities). Isolating all services instead brings unnecessary instance bloat and memory overhead.

The second is making event boundary conventions explicit. As the number of plugins grows, passing events by "visible by default" becomes increasingly dangerous—an event from group A is unexpectedly received by group C and triggers a side effect, and such problems are extremely hard to reproduce in multi-instance scenarios. Therefore, more and more teams are starting to classify events: global events (cross-group broadcast, going through the root scope, used for framework-level notifications) and local events (flowing within a group, going through the group scope, used for business logic). After classification, event names also carry distinguishable hierarchical prefixes to facilitate static retrieval.

The third is the idea of configuration as contract. Since isolate determines instance boundaries, the key parameters of each service in each group (such as the two timeout tiers 5000 and 60000 repeatedly mentioned earlier) should be managed as a public contract: written into the configuration, put under version control, and listed as a comparison table in the documentation. In this way, when someone asks "why does group A's command disconnect after 5 seconds," the answer is clear at a glance in the configuration, without needing to dig through the plugin source code.

The fourth is filling in the observability gap. Because the event call chain is invisible, runtime metrics such as listener counts, dispatch mode hit statistics, and short-circuit occurrence rates become especially important. Collecting them is equivalent to installing a dashboard for the "invisible call relationships." This is a systematic compensation for the inherent debugging cost of the event mechanism.

Summary and Best Practices

Here is a condensed, actionable checklist of the key points from this article. It is recommended that you put it directly into your project review checklist:

  • Isolation applies only to service instances, not to events: isolate determines "which instance you get," while the visibility scope of events is determined by scope. Think about these two things separately.
  • Use group + isolate as a set: isolate needs a group entry to carry it, so group: true and isolate: { shell: true } must appear as a pair.
  • Service configuration belongs under the plugin entry: parameters such as timeoutMs: 5000 must be attached to the config of the specific service plugin. If the level is wrong, it will be silently ignored.
  • Remember to spread and merge before returning from waterfall: use { ...payload, ...change } to avoid losing fields. What you return becomes the input to the next stage.
  • bail depends on an ordering contract: the first non-empty return terminates the chain, so the registration order must be explicitly agreed upon. Do not rely on random order.
  • Use serial for scenarios with ordered side effects: when you need serial guarantees, do not make do with emit. emit has broadcast-notification semantics.
  • Choose the event type by thinking about semantics before writing code: use emit for notification, waterfall for transformation, bail for interception, and serial for ordered side effects.
  • Use layered event names + shared types: use a unified prefix to prevent name collisions, and use TypeScript types to catch payload mismatches at compile time.
  • Check scope ownership first for cross-group communication: confirm whether the listener and trigger are attached to the in-group ctx or the root ctx. This is the first suspect when cross-group events fail.
  • If isolation is not taking effect, check in five steps: group declaration → isolate field → config level → instance namespace → scope ownership.
  • Build observability: track the number of listeners and the short-circuit rate, and install a dashboard for invisible event chains.
  • Refine isolation granularity as needed: isolate only the services that require differentiated configuration to avoid instance bloat.

At this point, the two lines of scope isolation and the event system come together: isolation governs "how the same service gets its own instance," while events govern "how plugins talk to each other without being aware of each other." Only by understanding them separately and cross-validating them can multiple plugins coexist peacefully in the same Harness without fighting.