When you first encounter the three terms Service Definition, Service Provider, and Consumer in DeepSeek Harness, you will likely take them as a set of abstract architectural jargon and feel they have little to do with actually writing code. But as soon as you set out to plug a new capability into Harness—something as simple and immediately obvious as "convert a piece of text to uppercase"—you will discover that what really determines whether this system can evolve over the long term is not how powerful the capability itself is, but whether the capability has been cut along a clear seam. The "seam" here does not refer to a code defect, but to a deliberately left replaceable joint—it keeps the interface, the implementation, and the model-facing tool each independent, so that swapping out any one of them does not drag the other two along. This article is aimed at advanced readers who are already comfortable with Harness and want a deeper understanding of its extension mechanism. We will first explain the relationship between a capability and its seam thoroughly, then walk you through writing a genuinely replaceable capability, myCap, from scratch, taking apart layer by layer the boundaries of the three packages Definition / Provider / Consumer, the registration timing, type declaration merging, request-result decoupling, tool wrapping, and dependency injection—so that you can personally grow a new organ for Harness that can be swapped out at any time and evolve independently.

Definition / Provider / Consumer: why a capability must be split into three packages

In the official documentation, these three terms often appear together, but rarely does anyone first explain clearly what each of them is responsible for. Let me start with the most basic judgment: only the three together constitute a complete capability, that is, a seam; no single role on its own is a seam. This statement is the foundation of the entire extension system. Definition is merely the interface and types, Provider is merely the implementation, and Consumer is merely the tool shell shown to the model—pull out any single role on its own, and it cannot claim "I have implemented a capability." Only when the three are assembled and each is in its proper place does the capability truly exist within Harness.

Let me reorganize the responsibility boundaries of the three roles to avoid confusion:

  • Service Definition: responsible for defining the Cordis service, as well as the types of the request Request and the result Result. It only declares "what capability exists and what it looks like," and does not care at all about how it is implemented. Taking Bash as an example, the Definition is the dsh-shell package, registered as ctx.shell, and defines the two types ShellExecRequest and ShellRunResult.
  • Service Provider: actually implements the capability, usually targeting one runtime environment. It inherits the abstract class of the Definition and fills in the concrete behavior. Bash's Provider is dsh-bash-local, which executes commands on the local computer; the same Definition also has other Providers such as dsh-bash-sandbox (executing in a sandbox) and dsh-pwsh-local (executing PowerShell).
  • Consumer: the model-facing tool, which exposes the capability as a tool the model can call. It wraps the capability into a tool schema so the model can call it, but it does not do the work itself. Bash's Consumer is dsh-tool-bash, which wraps ctx.shell into a model-callable bash tool.

The official reference documentation maintains the three-role ownership of ctx.shell in a single table, and it is well worth remembering because it turns "concepts" into a concrete mapping of "CTX key + package name":

ctx keyroleowning package (Definition)implementation (Provider)direct consumer (Consumer)
ctx.shellseamshellbash-local / bash-sandbox / pwsh-localtool-bash / tool-pwsh / hooks-claude-code / hooks-codex

This table hides a fact that is easy to overlook: the consumers are not limited to tool-bash. There are also tool-pwsh, hooks-claude-code, and hooks-codex—the two hook-bridging plugins also count as Consumers. Like tool-bash, they only recognize the ctx.shell interface and do not care at all whether what runs behind it is a local executor, a sandbox executor, or PowerShell. This is the value of the seam: there is no direct dependency between the consumer side and the implementation side; the only intermediary is the Definition.

示意图
Diagram of the three-role relationship of a capability seam: the Definition sits in the center, the Provider and the Consumer each depend only on it, and the two do not depend on each other. When swapping the Provider, neither the Definition nor the Consumer needs a single line changed.

Look at this relationship diagram a bit more closely, and its structure is very elegant: the Definition is in the middle, and both the Provider and the Consumer depend only on it. The Provider inherits and implements the Definition, while the Consumer depends on it via inject: ['shell']; and there is no dependency whatsoever between the Provider and the Consumer. This means that when swapping the Provider, neither the Definition nor the Consumer needs a single line changed. If you want to switch from local execution to sandbox execution, you only need to replace one line of the loading entry in cordis.yml, and the tool-layer code does not need to be touched at all.

But there is a criterion here that must be made clear: not every capability has to be split into three packages. The three roles can live in the same package, or be split into different packages, and there is only one criterion—whether these roles need to evolve or be replaced independently. If a capability will only ever have one implementation and will never need its executor swapped, then cramming the Definition, Provider, and Consumer into one package is perfectly fine. Conversely, as long as any link may need to change independently in the future, it should be split out. Splitting packages is not for looks, but to keep change isolated within the smallest possible scope.

There is another detail worth pulling out, as it embodies the design philosophy of "explicit is better than implicit at package boundaries." In the Bash seam, the model-facing request ShellExecRequest and the fully resolved specification ShellExecSpec actually used by the executor are deliberately kept separate: the former has only optional fields such as workdir and timeoutMs, while the latter has all fields required. The tool layer calls ctx.shell.resolve(request) between the two to complete the resolution. Why not let the tool layer directly construct a fully filled-in specification? Because model-facing input naturally may lack fields and need defaults filled in, whereas the executor is unwilling and unable to tolerate missing fields. Separating the "loose request on the model side" from the "strict specification on the executor side" at the package boundary, and explicitly completing the conversion with a single resolve, turns implicit assumptions into an explicit contract. This lesson will be used again and again when you build your own capabilities.

Now that you understand the three roles, let's move into hands-on practice. The official tutorial provides a target capability called myCap: it takes a piece of text as input and outputs it in all uppercase. It's small enough to grasp at a glance, yet it fully covers the three packages: Definition, Provider, and Consumer. The path is a three-step process: first write the Service Definition (abstract class + types), then write the Service Provider (implementation subclass), and finally write the Consumer (defineTool), and lastly compose and load the Provider and Consumer in cordis.yml. The focus of this section is to thoroughly break down the code and principles of the first two steps, leaving the third step for the latter half where we'll discuss it together with the inject mechanism.

Definition only writes the contract: the MyCapService abstract class and the registration timing of super(ctx, 'myCap')

The Service Definition declares the capability itself: what the service is called, how it's invoked, and what the types of requests and results are. It contains no implementation logic whatsoever—just one abstract method and two interfaces. The abstract class MyCapService extends Service and registers itself as a named service via super(ctx, 'myCap'). Let's paste this code first, then explain the reasoning behind each design decision line by line.

// File path: packages/my-cap/my-cap/src/index.ts
import { Service, type Context } from '@deepseek-ai/cordis'

// Declaration merging: gives ctx.myCap type hints in TypeScript
declare module '@deepseek-ai/cordis' {
  interface Context {
    myCap: MyCapService
  }
}

// Abstract class: the Definition package only declares the contract, no implementation
export abstract class MyCapService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'myCap') // Register as named service ctx.myCap
  }

  /** Execute the capability. */
  abstract execute(request: MyCapRequest): Promise<MyCapResult>
}

// Request type: the caller must provide input
export interface MyCapRequest {
  input: string
}

// Result type: the capability returns output
export interface MyCapResult {
  output: string
}

Let's first look at the named service registration step. The string 'myCap' in super(ctx, 'myCap') is the service name, and it determines which key this capability is mounted under on ctx. After registration, any code in the entire Harness that has access to a Context can reference this capability via ctx.myCap. Note the registration timing: registration happens in the constructor, i.e., the moment the service instance is created. This means that as long as the Provider is loaded, the ctx.myCap key will appear on the context, and other modules can inject it. You don't need to manually register it in some central registry—Cordis's Service base class does this for you.

Next is the abstract class itself. The abstract class in the Definition package only declares the contract and contains no implementation code—it only provides one abstract method, execute, with the signature execute(request: MyCapRequest): Promise<MyCapResult>. There are two design points here. First, the method is abstract, forcing every Provider to supply its own implementation, and the compiler will enforce this for you: a subclass that does not implement execute will not compile. Second, all types used in the signature come from the Definition package's own MyCapRequest and MyCapResult, rather than types introduced by some concrete implementation. The contract only describes the shape and does not leak implementation details, which is the prerequisite for the Definition package to remain stable over the long term.

Many people make a mistake the first time they write a Definition: they cannot resist adding a bit of "shared logic" to the abstract class, such as parameter validation or logging. In the short term this looks convenient, but it damages the purity of the seam. Once validation rules are written into the Definition, they bind all Providers; yet different Providers may have different pre-checks for the same request (a local executor cares about whether the command exists, while a sandbox executor cares more about permission policy). Keep validation in the Provider and keep the shape in the Definition, and only then is the boundary clean. If you really have a pure function that is reused across Providers, put it in a separate utility package rather than stuffing it into the Definition abstract class.

Now consider the fact that the request and result types are placed in the Definition package. Why should MyCapRequest and MyCapResult live in the same package as the abstract class, rather than being scattered separately? Because they are part of the contract. As long as the shapes of the request and result are the contract, any Provider and any Consumer reference the same type definition. If you let the Consumer define its own input type and the Provider define its own return type, then implicit parallel types appear among the three, and once the contract evolves, all three places must be changed in sync, and drift is only a matter of time. Pinning the types in the Definition package is the least effortful way to ensure that the three roles reference the same source of truth.

Finally, let us emphasize what the Definition package "does not do": it does not read environment variables, does not touch the file system, does not make network requests, and does not introduce any dependency related to the execution environment. Its dependency list should be very short, basically only Cordis's Service and Context types. You can use a very practical criterion to check whether your Definition package is qualified—compile it on its own, and if it pulls in any runtime dependency specific to an execution environment, that means implementation logic has seeped in. Keeping the Definition package lightweight is the prerequisite for the seam to be reused repeatedly.

declare module declaration merging: letting ctx.myCap get type hints in TypeScript

There is a piece of code above that looks a bit "magical," namely the declare module '@deepseek-ai/cordis' section. Many people just copy and paste it without knowing what problem it actually solves. This section breaks it down clearly, because if you do not understand it, you will keep suffering when writing your own capabilities.

The starting point of the problem is this: Cordis's Context interface originally declared only the framework's built-in services, such as ctx.shell, ctx.logger, and so on. When we attach myCap to ctx at runtime via super(ctx, 'myCap'), the TypeScript compiler does not know that ctx will have an extra myCap property. So when you write ctx.myCap.execute(...) elsewhere, the compiler reports that "property myCap does not exist on type Context." At runtime it clearly exists, but the type system cannot see it—this is the source of the disconnect.

The solution is declaration merging. TypeScript allows the same module to be declared multiple times, and the declarations are merged together rather than overwritten. We reopen Cordis's type module via declare module '@deepseek-ai/cordis', add an interface Context to it, and include the property myCap: MyCapService. After merging, every place that references the Cordis Context will see the new property, so:

  • Completion works: after typing ctx., the IDE will list myCap as a candidate and show that its type is MyCapService.
  • Type checking works: ctx.myCap.execute(request) will verify whether request satisfies MyCapRequest and whether the return type is MyCapResult.
  • Rename safety: if you rename the service from myCap to something else, the type system will flag all reference sites accordingly.

There are three engineering details worth remembering here. First, declaration merging takes effect globally: as long as this .d.ts or .ts file is included in compilation, its type augmentation applies to the entire project. So don't put declare module in some corner file that no one will ever import; usually put it in the main entry index.ts of the Definition package, so that anywhere referencing that capability also picks up the types. Second, the property type declared in the interface should be the abstract class type exported by the Definition package itself (MyCapService), not the subclass type of some concrete Provider. If you write a concrete subclass, then once you switch Providers the types no longer line up, and declaration merging instead becomes a stumbling block when swapping implementations. Third, runtime registration and type declarations must appear as a pair. Writing only super(ctx, 'myCap') without declaration merging causes a compile error; writing only declaration merging without runtime registration means ctx.myCap is undefined at runtime. Neither can be missing, and both must use the same name.

There is another easily overlooked pitfall: the string literal of the service name and the property name in the declaration merging must match strictly, including case. If you write 'myCap' in super and mycap in interface Context, TypeScript will not report a conflict (because these are two different property names), but at runtime accessing via ctx.mycap will yield undefined, while ctx.myCap, though accessible, has no type protection at all. This kind of problem is painful to troubleshoot because it doesn't throw a compile error and only blows up at runtime. The recommended approach is to decide the service name first, then have both the declaration merging and the runtime registration write against the same string, and if necessary extract the service name into a constant for reuse.

Looking at declaration merging together with the abstract class from the previous section, you'll find that the complete responsibility of the Definition package is actually just three things: use an abstract class to define the call entry point, use interfaces to define the request/result shapes, and use declaration merging to attach the service name to the Context type. All three revolve around the "contract" and none of them involve "how to do it." This is also why we repeatedly emphasized earlier that the Definition package should be lightweight—the less it takes on, the more broadly it can be reused.

MyCapRequest.input and MyCapResult.output: why request/result types should be decoupled from the implementation

Let's look at the two types of this minimal capability. MyCapRequest contains only one input: string, meaning the caller must provide a piece of input text; MyCapResult contains only one output: string, meaning the capability returns the processed text. It's that simple, but "simple" is exactly why we use it to demonstrate decoupling—in the smallest example it's easiest to see clearly what interface types should actually describe.

Let's start with a question: do these two interfaces describe a "shape" or a "behavior"? The answer is clear: they describe only a shape. MyCapRequest only says "the caller will provide a string"; it does not care where that string comes from, whether it is user input, or whether it has a length limit. MyCapResult only says "a string will be returned"; it does not care how the case conversion is actually computed or which character-set rules are used. Interface types are pure data contracts, containing no information about the execution environment.

This decoupling brings three direct benefits:

  1. The Provider can be replaced without changing the types. We can write a locally implemented Provider, a Provider that calls a remote service, or even a fake Provider that always returns a fixed string for testing—their execute signatures are completely identical, because the request and result types are pinned down in the Definition. When swapping Providers, not a single line of the Consumer's code needs to change.
  2. The Consumer can evolve independently. Today the Consumer might be a defineTool used by a model; tomorrow you might want to add a Consumer for the command line, or a Consumer for some automation workflow. They all construct MyCapRequest and consume MyCapResult without interfering with each other.
  3. Tests can be completed with minimal dependencies. Because the types describe only a shape, you can directly construct an object like { input: 'hello' } and pass it to execute, without starting any execution environment. This makes unit tests both fast and stable.

Thinking one level deeper, why should the request and the result be split into two types instead of reusing one? Because they describe the two ends of the lifecycle. The request is the "input side," often carrying optional fields and needing defaults to be filled in; the result is the "output side," often a definite product after execution. As mentioned earlier, ShellExecRequest and ShellExecSpec being separated in the Bash seam is exactly an extension of this idea: loose input oriented toward the model/caller and strict specifications oriented toward the executor are two things of different natures, and should not be forced into a single type. myCap is very simple now, but if in the future you want to add an optional locale field to MyCapRequest (to control the language environment for case conversion), the request type can accommodate optional fields while the result type remains just output, and the two evolve independently.

Here is a complete field description table for myCap, to make it easy for you to refer to when building your own capabilities:

TypeFieldRequiredMeaningDecoupling point
MyCapRequestinputRequiredThe input text provided by the callerNot bound to input source or validation rules
MyCapResultoutputRequired (as part of the return value)The output text after capability processingNot bound to processing algorithm or execution environment

Pay special attention to one engineering habit: do not add fields in advance just to make the type "look more general." Many people, when designing MyCapRequest, cannot resist adding big catch-all fields like metadata, options, or context, thinking "they might be useful later." As a result, every Provider has to handle a bunch of fields it does not need at all, and the Consumer also has to spend effort filling them in. Interface types should reflect the current real contract. When there really is a second Provider that needs extra information, then explicitly add the field to the request type and have all Providers upgrade together—this kind of "explicit breaking change" is far healthier than a vague catch-all field.

One more reminder: here we're using TypeScript's interface, which natively supports declaration merging and extension, making it well suited as a public contract. If you need stricter encapsulation semantics, you can also use type with readonly fields, but that's a stylistic choice and doesn't affect the core decoupling idea discussed in this article.The key to decoupling isn't whether you use interface or type, but whether these two types only describe shape, whether they live in the Definition package, and whether they are shared and referenced by all roles.

Provider inherits the abstract class: how a subclass fills in the concrete behavior of execute

Once the Definition is written and the contract is in place, next comes the Provider. The way to implement a Provider is very direct: inherit the Definition abstract class and provide the single implementation entry point—that is, fill in the abstract method execute with concrete behavior. Let's write a local Provider for myCap; all it does is convert the input string to uppercase.

// File path: packages/my-cap/my-cap-local/src/index.ts
import { Context } from '@deepseek-ai/cordis'
import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/my-cap'

export class MyCapLocal extends MyCapService {
  constructor(ctx: Context) {
    super(ctx) // Reuse the Definition's named service registration logic
  }

  async execute(request: MyCapRequest): Promise<MyCapResult> {
    // The single implementation entry point: convert the input to uppercase
    const output = request.input.toUpperCase()
    return { output }
  }
}

Let's go through the finer points of this code one by one. First, the constructor's super(ctx) isn't empty—it ultimately reaches the Definition abstract class's constructor, that is, super(ctx, 'myCap').The named service registration logic is written only once in the Definition, and all Providers get it for free through inheritance. This is the value of putting registration in the abstract class constructor: Providers don't have to repeat the string 'myCap', thereby avoiding the problem of the service name being manually synchronized across multiple Providers, where a single wrong letter registers it under the wrong key. Note that the Provider's constructor in the code above can actually be omitted (the inheritance chain will automatically forward ctx); I wrote it out explicitly just so you can see the call chain clearly—in real projects you can leave it out.

Second, the signature of execute must match the abstract method exactly: the parameter is MyCapRequest, and it returns Promise<MyCapResult>.Returning a Promise is deliberate, because real capability implementations usually involve I/O (reading files, going over the network, invoking processes), and designing the interface as asynchronous lets Providers freely choose a synchronous or asynchronous implementation without having to change the contract later just for asynchrony. In the myCap example, toUpperCase itself is synchronous, but we still return a Promise wrapped in an async function, precisely to align with the contract.

Third, a Provider can do anything related to the execution environment: read environment variables, access the file system, load native modules. Things that are forbidden in a Definition are allowed in a Provider, because that is precisely the point of a Provider's existence. But note that a Provider should not care about "how the model calls me". It only handles an already-constructed MyCapRequest and returns a MyCapResult. Leaving "parsing model-facing input" to the Consumer is the key to keeping a Provider reusable—the same Provider can be called by a tool-type Consumer, by a hook-type Consumer, or even directly by test code.

Fourth, the cost of swapping Providers. Suppose we want to write a MyCapRemote that sends the input to a remote service to do the uppercasing. The code structure is exactly the same, except that the body of execute is replaced with a network request. Since it likewise extends MyCapService and is likewise registered as ctx.myCap, the Consumer has no way of noticing that the Provider behind it has been swapped. This is exactly the payoff of the seam design: change is isolated within a single package.

A common engineering pitfall is worth pointing out: don't let a Provider quietly modify the request object inside execute. For example, uppercasing request.input in place and then returning it. Although this looks fine in the simple myCap scenario, when multiple Consumers share the same request object, or when a Provider is called in a chain, in-place modification causes side effects that are hard to trace. The correct approach is to read only, never modify, and return a brand-new result object. The return { output } in the code above is an embodiment of this principle. This habit is especially important in more complex seams (such as Bash execution), because the request object often carries fields like workdir and timeoutMs that affect execution semantics; once a Provider tampers with them, the cost of troubleshooting is extremely high.

Wrapping with defineTool in the Consumer: exposing a capability as a model-callable tool schema

Now we have both the Definition and the Provider, but the model still can't see this capability. That's because the Provider offers a programmatic interface, ctx.myCap.execute(...), whereas what the model can call is a tool—a declaration with a name, a description, and a parameter schema. Translating the former into the latter is the Consumer's job. The Consumer faces the model, wrapping the capability into a tool schema so the model can call it, while it does not do the actual work itself.

Wrapping it with defineTool roughly yields the following shape. Note that this is a role demonstration; the point is the structure and the division of responsibilities, not the fixed API details of any particular framework.

// File path: packages/my-cap/my-cap-tool/src/index.ts
import { defineTool } from '@deepseek-ai/dsh-tool'
import { MyCapService, type MyCapRequest } from '@deepseek-ai/my-cap'

export const myCapTool = defineTool({
  name: 'my_cap',
  description: 'Convert the input text to uppercase',
  parameters: {
    type: 'object',
    properties: {
      input: {
        type: 'string',
        description: 'The text to convert',
      },
    },
    required: ['input'],
  },
  async execute(args, ctx) {
    // The Consumer is only responsible for translating the arguments into a request, then calling ctx.myCap
    const request: MyCapRequest = { input: args.input }
    const result = await ctx.myCap.execute(request)
    return result.output
  },
})

In this code, the division of responsibilities is very clear: name and description are semantic information for the model, which the model uses to decide when to call this tool; parameters describes the parameter shape according to JSON Schema, and it stays consistent with the shape of MyCapRequest (one required string input), but they are two different layers—the former is oriented toward the model's function calling, while the latter is oriented toward parameter passing inside the program; what execute does is translation, converting the args given by the model into MyCapRequest, then calling ctx.myCap.execute, and finally returning result.output to the model. Throughout the whole process, the Consumer does not implement any "case conversion" logic; it is just a porter.

The value of doing this is: the same capability can have multiple Consumers, and they share the same Provider. As mentioned earlier, the consumers of the Bash seam include not only tool-bash, but also tool-pwsh, hooks-claude-code, and hooks-codex. These Consumers expose different forms to the model or hooks, but underneath they all call ctx.shell. Likewise, you can absolutely write another command-line Consumer besides myCapTool, or a Consumer that batch-processes in CI; they all call ctx.myCap without interfering with each other. If you do not separate the Consumer out, but instead hard-code the "tool schema" directly into the Provider, then every time you add a consumption form, you have to copy a Provider, and the evolution of the Provider and the Consumer becomes tied together.

Here is a very practical engineering checklist for judging whether your Consumer has overstepped its bounds:

  • Does the Consumer contain code related to the execution environment (reading/writing files, starting processes)? If so, it means you have moved the Provider's work into the Consumer.
  • Does the Consumer duplicate parameter validation logic that should have been in the Provider? If so, push the validation down into the Provider, and let the Consumer only do format translation.
  • Does the Consumer directly import a specific Provider class? If so, it means it has bypassed the Definition, and the seam has been broken; it should depend only on the ctx.myCap interface.
  • Does the tool's parameter schema stay shape-consistent with the request type? If the two begin to drift, the model will fill in parameters that the Provider cannot handle.

Also note that the quality of the description directly affects whether the model will call this tool. In the Bash example, the tool description should let the model know that this is a tool for executing shell commands; in the myCap example, the description should let the model understand "input a piece of text, get the uppercase version." The tool name plus parameter names plus description together constitute all the information the model sees, and none of them can be missing. If the description is vague, the model may never call it, and no matter how beautifully you write it in the Provider, it will not be used.

inject: ['shell'] and ctx.shell.run(...): the Consumer recognizes only the interface, not the executor

Finally, let us break down the most typical call chain in the official Bash example, and put the sentence "the Consumer recognizes only the interface, not the executor" into code. The tool-bash Consumer does not import any executor; it does two things: use inject to declare the dependency ctx.shell, and then call ctx.shell.run(...) in execute.

Let's start with the dependency declaration. inject: ['shell'] expresses "my capability depends on a service named shell". Note that what is injected is the service name shell, not some package or some class. This means the Consumer knows nothing about "who provides the shell service", and doesn't care in the slightest. The runtime assembly is handled by cordis.yml; whichever Provider you load in the config file, tool-bash automatically uses it. Below is a config snippet from the official docs, which makes it clear that switching Providers only requires changing one line:

# File path: cordis.yml

# Local execution
- name: '@deepseek-ai/dsh-bash-local'

# When you want to switch providers, just replace the line above.
# Replace it with the line below to switch to the sandbox executor:
# - name: '@deepseek-ai/dsh-bash-sandbox'

These few short lines are the most powerful evidence of the entire seam design. Swap dsh-bash-local for dsh-bash-sandbox, and the execution environment switches from the local machine to a sandbox, while not a single character of tool-bash's code or the Definition's definition needs to change. Why does this work? Because tool-bash depends solely on the ctx.shell interface from start to finish; it calls ctx.shell.run(...), not new BashLocal().run(...). Whoever registers ctx.shell at runtime is who it uses.

Now look at the resolve step in the call path. As mentioned earlier, in the Bash seam, the model-facing ShellExecRequest (with optional workdir and timeoutMs) is separate from the ShellExecSpec actually used by the executor (with required fields), and the tool layer calls ctx.shell.resolve(request) between the two to complete the resolution. So the full path is roughly: the model provides loose parameters → the Consumer assembles them into a ShellExecRequest → calls ctx.shell.resolve(request) to obtain the strict spec ShellExecSpec → calls ctx.shell.run(...) to hand it to the currently registered Provider for execution. On this chain, resolve is provided by the Definition side (because it is part of the contract), the concrete behavior of run is provided by the Provider, and the Consumer is only responsible for moving things between the two.

Mapping the Bash experience back to myCap, the logic is exactly the same. myCapTool should declare via inject that it depends on the myCap service, and then call ctx.myCap.execute(request) inside execute, rather than directly new-ing a MyCapLocal. This way, when you want to switch from local uppercase conversion to remote uppercase conversion, you only need to change one line of Provider in cordis.yml, and the tool layer stays untouched. myCap doesn't yet involve a resolution step like resolve, but if in the future MyCapRequest gains optional fields and the Provider needs a strict spec filled with default values, you can absolutely copy the Bash seam's approach and add a resolve in the Definition, making the "loose request → strict spec" conversion explicit.

Finally, let's wrap up this mechanism with a comparison table, placing the differences between the two approaches—"depending on the interface" and "depending on the implementation"—side by side:

DimensionDepending only on the interface (inject: ['shell'])Depending directly on a concrete implementation
Does switching Provider require changing the ConsumerNo, just change one line in cordis.ymlYes, it must be changed, often requiring rewriting imports and calls
Does the Definition need to changeNoUsually not either, but the seam has already been broken
Can multiple Providers be integrated simultaneouslyYes, the config decides which one is loadedNo, it is hard-coded and bound
Can the Consumer be reusedYes, multiple Consumers share the same interfaceDifficult, strong coupling leads to copy-paste
Testing difficultyLow, a fake Provider can be injectedHigh, a real implementation must be constructed

At this point, all three pieces of the puzzle are in place: Definition uses abstract classes, request result types, and declaration merging to define the contract; Provider inherits from the abstract class and fills in the concrete behavior of execute; Consumer uses defineTool to translate the interface into a tool schema that the model can call, and through inject it only recognizes the interface, not the executor. Although myCap is small, it has fully walked through the three-role flow of a seam, and it follows the same structure as a heavyweight capability like Bash. Once you understand this structure, you have mastered the basic technique for growing new organs for the Harness. Next, we need to lift our view from a "single capability" to a "capability system," and look at what new problems assembly, dependency resolution, and lifecycle bring when multiple seams exist at the same time, as well as what boundaries and pitfalls self-built capabilities still have to handle in real engineering.

In the previous section, we wrote from scratch the Definition, Provider, and Consumer packages of a complete capability, myCap, and also confirmed that changing one line in cordis.yml can switch to a different implementation. In this section, we turn our view back to the core seam of DeepSeek Harness itself, using ctx.shell (Bash execution capability), the one with the most complete evidence in the official repository, as the object of dissection, and thoroughly explain configuration switching, package ownership, the boundary between requests and specifications, hook-style Consumers, physical package-splitting tradeoffs, a troubleshooting checklist, and the latest rollout order as of September 2026.

Changing one line in cordis.yml switches the executor: three Providers for local / sandbox / PowerShell

In Harness, "switching an executor" is never about modifying tool code, but about modifying the assembly file. cordis.yml is Cordis's assembly manifest; it determines which plugins are loaded in the current process, in what order they are mounted, and which service graph they are assembled into. One of the design goals of the Bash seam is to make the choice of provider sink entirely into the configuration layer. With the same ctx.shell service definition and the same dsh-tool-bash tool, pairing it with different Provider packages changes the behavior from "execute directly on the local machine" to "execute in a sandbox" and then to "call PowerShell."

The minimal switching snippet given in the material is assertion-style: local execution loads @deepseek-ai/dsh-bash-local, and when you want to switch providers, you only replace this line; switching to @deepseek-ai/dsh-bash-sandbox switches to the sandbox executor. We expand it into a complete configuration that can be pasted directly, listing the three Providers side by side, commenting out two of the lines, and leaving only one active:

# File path: cordis.yml
# ---------------------------------------------------------------
# Provider selection for the Bash seam: enable only one at a time
# The other two lines remain commented out; when switching is needed, just "change one line"
# ---------------------------------------------------------------

plugins:
  # Definition side: register the ctx.shell service definition (dsh-shell package)
  - name: '@deepseek-ai/dsh-shell'

  # ---- Provider: choose one of three ----

  # Option A: local execution (directly spawn the command in the host machine's current working directory)
  - name: '@deepseek-ai/dsh-bash-local'
  # Option B: sandbox execution (execute the same command in an isolated environment)
  # - name: '@deepseek-ai/dsh-bash-sandbox'
  # Option C: PowerShell execution (executor for Windows / pwsh environments)
  # - name: '@deepseek-ai/dsh-pwsh-local'

  # ---- Consumer: the tool layer facing the model ----
  # The tool layer only injects 'shell' and does not care which Provider is selected above
  - name: '@deepseek-ai/dsh-tool-bash'

There are three engineering details in this configuration that you must see clearly.

The first detail is that the loading order of Providers must align with Definitions. Cordis's service assembly follows a dependency order: Definitions provide abstract services, Providers inherit and register concrete implementations, and Consumers declare their dependency on services via inject. If a Provider is placed before a Definition, the service hasn't been registered yet, and the plugin won't be able to obtain a usable ctx.shell. In production configurations, the safe approach is to put Definitions first, Consumers last, and Providers in between.

The second detail is mutually exclusive loading. In theory, all three Providers can be installed into the process simultaneously, but registering them onto the same service key ctx.shell will cause overwriting or conflicts: both bash-local and bash-sandbox want to be the implementation of ctx.shell, and which one ultimately takes effect depends on the loading order—this is a classic case of "implicit behavior." Therefore, the recommended practice is always to enable only one Provider at a time, keeping the rest commented out, so that "which executor is chosen" is readable at a glance in the configuration. This is far cleaner than using environment variables to make branching decisions in code—code branches are implicit choices at runtime, whereas configuration files are explicit choices that are statically auditable.

The third detail is the raison d'être of the PowerShell Provider. It shows that the dimension along which the seam splits is not "command line vs. something else," but "execution environment." bash-local targets POSIX-like environments, bash-sandbox targets isolated environments, and pwsh-local targets PowerShell environments. All three share the same ShellExecRequest and ShellRunResult type contracts, so dsh-tool-bash doesn't need to know at all who is standing behind it. This is precisely the proof that when swapping Providers, "not a single line of Definition or Consumer needs to change": the switching action is semantically equivalent to swapping out a pluggable organ, while the nervous system (the tool layer) and the skeleton (the type definitions) remain completely untouched.

We can also take the opportunity to make a more engineering-oriented variant: elevate Provider selection to "one file per environment." For example, maintain separate copies of cordis.local.yml, cordis.sandbox.yml, and cordis.ci.yml, and specify which one to use at startup with --config. This way, the CI environment naturally runs the sandbox, developers' local machines naturally run the local executor, and all business code and tool definitions remain completely consistent.

ctx.shell Three-Role Attribution Table: Which Package Each of Definition, Provider, and Consumer Belongs To

The fastest way to understand a seam is to lay it out according to the table structure in the official "Capability Seam and Core Services" reference documentation. The attribution of ctx.shell can be clearly explained with five columns: "ctx key / role / owning package (Definition) / implementation (Provider) / direct consumer (Consumer)." The following table restates it according to the structure of the official reference documentation:

ctx key Role Owning package (Definition) Implementation (Provider) Direct consumer (Consumer)
ctx.shell seam (a complete capability) shell (i.e. dsh-shell, which registers ctx.shell) bash-local / bash-sandbox / pwsh-local tool-bash / tool-pwsh / hooks-claude-code / hooks-codex

The value of this table is that it exposes three easily confused facts all at once.

First, the Definition is a single package, while Providers and Consumers are plural. On the Definition side, only one package, dsh-shell, maintains the contract; it defines the request type ShellExecRequest and the result type ShellRunResult. On the Provider side there are at least three executors, and on the Consumer side there are at least four consumers. The entire seam is a "one-to-many-to-many" star structure, with the Definition at its center.

Second, Consumers are not limited to model-facing tools. Most people's first instinct is that "a Consumer is just a tool the model can call," so seeing tool-bash and tool-pwsh feels perfectly natural. But the table also lists hooks-claude-code and hooks-codex, two hook-bridging plugins—they are Consumers too, except that what they face is not the model but the hook events of external toolchains. This point will be expanded on separately below.

Third, Providers and Consumers do not depend on each other. There is no cross-reference whatsoever between the Provider column and the Consumer column in the table. Providers inherit the Definition's abstract class and fill in behavior; Consumers depend on the same Definition via inject: ['shell']. There is no import relationship between the two. Delete every line connecting Providers and Consumers and keep only their respective lines to the Definition, and the whole diagram still holds—that is the shape of a seam.

One point deserves special emphasis: the complete capability constitutes its seam, and no single role is the seam. A lone dsh-shell is just a type declaration, a lone dsh-bash-local is just a piece of implementation, and a lone dsh-tool-bash is just a tool shell. Only when all three are present can we speak of "a replaceable capability." This definition is especially useful when troubleshooting: when you find that a capability cannot be swapped out, it is often not that the Provider was written poorly, but that an independent Definition is missing—once the type contract is casually defined by the Provider inside the implementation package, the Consumer inevitably imports the implementation package as a dependency, and the seam is broken.

ShellExecRequest and ShellExecSpec: resolve(request) is explicitly better than implicitly at the package boundary

The most intriguing design in ctx.shell is splitting "the request proposed by the model" and "the spec required by the executor" into two types. The material clearly states: the model-facing request is ShellExecRequest, containing workdir and an optional timeoutMs; the spec actually used by the executor, with all fields fully resolved and required, is ShellExecSpec. Between them, the tool layer calls ctx.shell.resolve(request) to perform the conversion. The author's comment is "explicit is better than implicit at the package boundary."

First, look at the lenient side. ShellExecRequest is the input produced by the model, and it is naturally "incomplete": the model may give a command but not specify a working directory, may or may not specify a timeout, and may give a relative-path workdir (relative to what?). If this object were thrown directly to the executor, the executor would have to fill in the gaps everywhere itself: read the default working directory, fill in the default timeout, and resolve relative paths on its own. Once fallback logic is scattered across every Provider, bash-local and bash-sandbox will each fill in their own set of defaults, causing subtle drift in behavior between the two, and such drift is extremely difficult to test.

Now look at the strict side. ShellExecSpec is the input the executor truly needs: all fields are required, workdir is an already-resolved absolute path, and the timeout is an already-computed concrete millisecond value. Once the executor receives it, it can spawn directly without any secondary inference.

The gate in between is resolve. It takes on four kinds of responsibilities:

  • Fill in defaults: complete the fields omitted in request with runtime-agreed values (such as applying a unified default when timeoutMs is not provided), rather than letting each Provider decide on its own.
  • Perform path resolution: resolve the workdir given by the model into a definite absolute path, eliminating the ambiguity of "relative to what."
  • Perform parameter validation: intercept obviously invalid requests before they cross the package boundary and throw a unified error, rather than letting the error explode deep inside the Provider.
  • Leave an anchor for auditing: because all requests must pass through resolve, centrally logging, instrumenting, and performing policy checks at this one place is the only audit point that cannot be missed.

Here is a table that pins down the differences between the two sides:

Dimension ShellExecRequest (model-facing) ShellExecSpec (executor-facing)
Producer Model / Consumer tool layer Produced by ctx.shell.resolve(request)
Field completeness Loose, some fields may be omitted Required, all fields fully resolved
workdir Optional, may be a relative path Required, a resolved definite path
timeoutMs Optional Required, a concrete value already filled in
Who handles fallbacks No one, deferred to resolve No one, the value received is final
Whether validation is needed No, it represents "user intent" Yes, validation is completed before crossing the boundary

Why is this a case of "explicit over implicit at package boundaries"? Because if you skip resolve and let each Provider read request.workdir ?? process.cwd() itself, then the decision of "what the default working directory is" gets implicitly scattered across every Provider implementation. Today bash-local uses the process's current directory, tomorrow bash-sandbox uses the sandbox mount root, and the day after a newcomer takes over pwsh-local and fills in a drive-letter root on Windows—the same model request, three executors, three behaviors, and the caller has no way of knowing.

Adding an explicit resolve is equivalent to planting a sign at the package boundary: "Once you pass through this door, every parameter has a definite value." All code after crossing the boundary no longer needs to ask "what if it's empty"—that is the payoff of being explicit. It is also extremely test-friendly: resolve is a purely functional input-output transformation that can be unit-tested independently of the real execution environment; once a Provider receives a ShellExecSpec, it only tests "can a given definite input produce the correct result." The two kinds of tests each converge on their own.

When writing your own seam, you can copy this pattern directly: for any interface that needs to cross packages, define the trio of "loose request + strict spec + explicit resolve." For example, if myCap is to support multiple text processors in the future, you can keep MyCapRequest on the loose side, then introduce MyCapSpec and expose ctx.myCap.resolve(request), so that all Processors share the same set of defaults and validation rules.

hooks-claude-code and hooks-codex: bridge plugins that consume ctx.shell without writing tools

In the official table, the direct consumers of ctx.shell include not only tool-bash and tool-pwsh, but also hooks-claude-code and hooks-codex. These two are hook bridge plugins, and their very existence redefines what a "Consumer" is.

By the most intuitive understanding, a Consumer is "a model tool that wraps a capability into defineTool." In that case, tool-bash is responsible for wrapping ctx.shell into a bash tool schema that the model can invoke; when the model says "list the directory for me," the tool layer calls ctx.shell.run(...) inside execute. This is the most standard form.

But hook bridge plugins take a different path. They target the hook events of external Agent toolchains—Claude Code's hooks and Codex's hooks both fall into this category. When some event in an external toolchain fires, the bridge plugin needs to execute a command to complete the response (for example, performing a status check or running a custom script). It does not need to expose the capability to the model, so it needs no tool schema, no model-readable tool description, and no handling of the model's argument assembly; it only needs to call ctx.shell directly.

The key point is this: like tool-bash, it only recognizes the ctx.shell interface and does not care which executor lies behind it. The original source material puts it this way: "Like tool-bash, they only recognize the ctx.shell interface and do not care which executor lies behind it." This statement carries strong engineering implications:

  • The form of a Consumer is open-ended. A tool is one kind of Consumer, a hook bridge is another, and in the future there may be Consumers such as scheduled tasks, HTTP entry points, and CLI subcommands. They share exactly one thing in common: they depend on the Definition, not on the Provider.
  • Adding a new Consumer requires no changes to the Provider or the Definition. When you need to "execute a shell command within some external event," you don't need to add code to bash-local; you just write a new Consumer package and inject: ['shell'].
  • The capability reuse rate is significantly raised. The same Bash execution capability is shared by more than four consumers. If the execution logic were copied every time a new consumption method was added, the code would quickly diverge; only by funneling it through the seam into the Definition can the marginal cost of adding new consumption methods be low enough.

From a testing perspective, hook-type Consumers are also easier to verify. Because they don't involve model interaction, the input is a hook event payload and the output is the side effect of a command execution. When writing an integration test, you can directly construct an event and assert that ctx.shell was called with the correct arguments—just mock out ctx.shell, and there is no need to actually execute a process.

This also points to a design anti-pattern: if private execution logic is inlined in tool-bash, then the hooks bridge plugin cannot reuse it and has to write another copy. Two copies of execution logic mean two sets of timeout policies, two sets of error handling, and two sets of working directory conventions, and the resulting consistency problems will surface in the form of "why does the model call work, but the hook trigger fails?" Putting execution capability into ctx.shell and splitting the consumption methods into independent Consumers is precisely to avoid this situation.

Whether to put the three roles in one package or split them into three packages: the only criterion is whether they can evolve independently

The source material gives a clear answer: the three roles can be placed in the same package, or split into different packages; there is only one criterion—whether these roles need to evolve or be replaced independently. This statement is worth expanding into an actionable decision method.

First, consider the reasonableness of "putting them in the same package." For a small internal tool-type capability, where all three roles exist only once, are not intended to be replaced, and are not intended to have their consumption methods extended, putting them in one package is entirely justified. One package means one installation, one build, and one version release, with the lowest maintenance cost. In the demo scenario, myCap can absolutely combine all three into one.

Now consider the conditions under which "splitting into three packages" pays off. Splitting is worthwhile only when any one of the following holds:

  1. The Provider needs multiple copies. When local, sandbox, and PowerShell execution environments coexist as they do for Bash, the Provider must be able to be installed, published, and upgraded independently. It is also possible to stuff three executors into one package, but that forces users to install all environment dependencies—installing POSIX dependencies on Windows is a pure burden.
  2. The Consumer needs multiple copies. The four consumers tool-bash, tool-pwsh, hooks-claude-code, and hooks-codex each have their own use cases. If they were combined in one package, a project that only uses hooks would also have to bring in tool-layer dependencies.
  3. The Definition is far more stable than the implementation. Once an interface is published, it should change as little as possible, while the Provider needs to frequently adapt to environmental changes. Putting the two in the same package entangles interface changes with implementation changes under the same version number, and semantic versioning cannot express "the interface did not change, but the implementation was upgraded."
Scenario characteristics Recommended physical structure Reason
Each of the three roles exists only once, with no plan to replace them Single package with three files (definition / provider / consumer) Lowest maintenance cost, avoids over-engineering
The Provider needs to switch by environment Definition as its own package, Providers each as their own package Allows users to install only the executors they need for their environment
Consumer forms are diverse (tools + hooks + others) Consumers each as their own package Avoids pulling in unused dependencies
The interface needs long-term stability while the implementation iterates frequently Definition and Provider at least separated Version numbers can independently express interface stability and implementation changes

One common misconception needs a special warning here: splitting is not the goal; replaceability is the goal. Some teams immediately split the three roles into three packages, only to end up with a Definition package that contains nothing but one abstract class and two interfaces, while the Provider package and the Consumer package both import each other. The packages are split, but the dependencies are not, so the seam still does not hold—and you have merely added build configuration and release pipelines for two more packages. The criterion is always "can it evolve or be replaced independently," not "how many packages did the official implementation split it into."

Another practical point is the relationship between physical package separation and dependency direction. There are only two legal dependency directions: Provider → Definition and Consumer → Definition. Any reference of the form Provider → Consumer or Consumer → Provider breaks the contract, whether in a single package or across multiple packages. In a single package, enforce it with directory structure and lint rules; across multiple packages, enforce it naturally with dependency declarations in package.json—if a dependency on the Consumer package appears in the Provider package, code review should block it immediately.

Checklist: Provider and Consumer do not depend on each other, so which files require zero changes when swapping the Provider

After swapping out the Provider, how do you confirm you actually got it right? The checklist below can be used directly as a verification script after the replacement operation. There is only one core proposition: Provider and Consumer do not depend on each other, so when swapping the Provider, Definition and Consumer should remain unchanged.

The recommended order of operations is as follows:

  1. Freeze the baseline before replacement. Record the current file hashes or commit IDs of dsh-shell (Definition) and dsh-tool-bash (Consumer) as evidence that "not a single line was changed."
  2. Change only cordis.yml. Replace @deepseek-ai/dsh-bash-local with @deepseek-ai/dsh-bash-sandbox (or dsh-pwsh-local), leaving all other lines untouched.
  3. Check dependency declarations. Confirm that no Consumer package appears in the Provider package's dependencies, and no Provider package appears in the Consumer package's dependencies. If either appears, the seam has already leaked; fix the dependency direction before continuing.
  4. Run the same set of regression cases. The same batch of model requests (including boundary samples such as omitting workdir, omitting timeoutMs, and passing relative paths) should produce semantically consistent results and errors under the new Provider.
  5. Verify that resolve behavior is unchanged. Default values, path resolution rules, and validation error messages come from the Definition side's resolve, and these should be exactly the same after swapping the Provider. If differences appear, it means the default-value logic was incorrectly placed in the Provider.
  6. Compare file hashes. The file hashes of Definition and Consumer match the baseline; only then does the replacement pass.

The table below serves as a quick reference to help distinguish "which files should be changed" from "which files should not be touched at all":

File / Package Role Changed when swapping Provider? Reason
cordis.yml Assembly manifest Change (usually just one line) Provider selection is pushed down to the configuration layer
dsh-shell (Definition) Interface and types Zero changes ShellExecRequest / ShellRunResult are independent of Provider
dsh-tool-bash (Consumer) Model tool Zero changes Only inject: ['shell'], never imports a concrete Provider
hooks-claude-code / hooks-codex Hook-type Consumer Zero changes Likewise only recognizes the ctx.shell interface
The Provider package itself Implementation No need to modify the old Provider, only add/remove assembly entries Adding a new executor means adding a new package + one new line of configuration

The three most common types of "false passes" encountered during troubleshooting should also be called out.

The first is baking default values into the Provider. On the surface, swapping the Provider succeeds, but as soon as the new Provider's default for timeoutMs differs from the old one by 500 milliseconds, behavior drifts. The way to detect this is to compare whether the ShellExecSpec produced by resolve is consistently identical regardless of Provider.

The second is hardcoding the executor name as a string inside the Consumer. For example, writing into the tool layer "if it's currently sandbox, take another branch." This is an implicit dependency on the Provider and invalidates the promise of "swap one line of configuration." The correct approach is to have the Consumer only call ctx.shell.run(...), leaving all environment differences to the Provider.

The third is the Provider secretly importing a helper function from the Consumer. This is especially prone to happen in a single-package, three-file structure: the Provider wants to reuse the tool layer's parameter-cleaning logic, imports it along the way, and thus the dependency direction is reversed. The fix is to move this logic up into the Definition's resolve, making it part of the contract.

Latest practice as of September 2026: Interface-first capability stitching and the path to landing Provider replaceability

Based on current practice as of September 2026, the recommended order for stitching a new capability into Harness is interface first, then Provider, and finally Consumer. This order is not an aesthetic preference but is determined by the direction of dependencies: Definition is the only node that both sides depend on together. Stabilize it first, and then both sides can proceed in parallel without blocking each other.

Phase 1: Write the Definition, declaring only the contract. Following the myCap approach in the reference material, the abstract class MyCapService extends Service and is registered as a named service via super(ctx, 'myCap'); use declare module for declaration merging so that ctx.myCap has type hints in TypeScript; then define MyCapRequest and MyCapResult. The key discipline at this phase is that no implementation logic appears in the package—only one abstract method and two interfaces. At the same time, it is advisable to settle the signature and default-value semantics of resolve(request) as early as possible, because it is the convergence point from "loose request → strict specification"; settling it late will cause subsequent Providers to each implement their own fallback.

Phase 2: Write the Provider, filling in concrete behavior. The Provider extends the abstract class from the Definition and implements execute. If multiple execution environments are anticipated, make the Provider a separate package at this point and distinguish environments by different package names, selecting among them via cordis.yml during assembly. Internally, the Provider only handles "given a strict specification, how to execute"; it does not perform parameter inference.

Phase 3: Write the Consumer, wrapping it as a tool for the model. The template for this step is defineTool: declare the tool schema, and in execute call ctx.myCap.resolve(request) to complete parameter convergence, then call ctx.myCap.execute(spec). Dependencies are declared via inject, and no Provider package is ever imported.

Phase 4: Assembly and verification. In cordis.yml, load the Provider and Consumer together, run the end-to-end use case, and then perform a "zero-change Provider swap" verification according to the troubleshooting checklist in the previous section. Only after this step is done is the capability truly stitched in.

Solidifying this order yields a very useful byproduct: the capability inventory can be enumerated by Definition rather than by implementation. When every seam has an independent Definition, you can at any time answer "what replaceable capabilities does the current system have?", which is valuable for both architecture review and permission governance.

Finally, one lesson repeatedly validated in 2026 practice: do not aim for completeness in the first version of a Definition. An interface that is too large means implementers must fill in a bunch of unused fields, and it also means you will not dare to change it in the future for compatibility. First define the minimal usable request and result types, leave resolve as an extension point, and when adding fields later, prioritize giving default values, so that the semantic version of the interface can remain at the same major version for a long time. Conversely, if the first version stuffs all possible execution options into ShellExecRequest, Providers will disagree on "which fields are required and which can be omitted", and the cleanliness of the seam will be quickly eroded.

Summary and Best Practices

Condense the key points of the entire article into an actionable checklist:

  • Keep the definition of seam firmly in mind: A complete capability's seam is jointly constituted by Definition, Provider, and Consumer; no single role alone is the seam.
  • Memorize the responsibilities of the three roles: Definition only declares "what capability exists and what it looks like"; Provider inherits the abstract class and fills in the implementation; Consumer wraps the capability into a tool schema for the model.
  • Preserve the dependency direction: There are only two legal edges, Provider → Definition and Consumer → Definition; Provider and Consumer do not depend on each other.
  • Push replacement down to configuration: To swap the executor, change only one line in cordis.yml; choose one of bash-local, bash-sandbox, or pwsh-local, and it is recommended to enable only one Provider at a time.
  • Verify package ownership against the table: The Definition of ctx.shell is dsh-shell, the Providers are bash-local / bash-sandbox / pwsh-local, and the Consumers are tool-bash / tool-pwsh / hooks-claude-code / hooks-codex.
  • Separate request from spec: The model-facing ShellExecRequest (workdir and timeoutMs optional) and the executor-facing ShellExecSpec (fields required) must be kept separate, with ctx.shell.resolve(request) as the convergence point in between, adhering to "explicit over implicit at package boundaries."
  • Expand the imagination of Consumers: Hook-bridging plugins, like tools, are Consumers; they only recognize the ctx.shell interface and do not care about the executor behind it. Therefore, when adding a new consumption method, do not modify the Provider; only add a new Consumer package.
  • Use only one criterion for package splitting: Whether the three roles can evolve or be replaced independently is the sole basis for deciding whether to keep one package or split into three, avoiding splitting for splitting's sake.
  • Verification is mandatory after replacement: When swapping a Provider, the Definition and all Consumers should remain unchanged; confirm this in three ways: file hash comparison, dependency declaration checks, and regression test cases for the same group.
  • Insist on interface first: The implementation order is Definition → Provider → Consumer → assembly verification; keep the first version of the Definition minimally usable and leave resolve as an extension point.