Certified Agent Harness Engineer
Course sections

M7.2

Service Definition / Provider / Consumer

1,448 words · about 9 min

This is the module's core transferable pattern. Learn it here and you will recognise it in codebases that have never heard of Cordis.

Concept

A capability seam splits one capability into three roles:

RoleOwnsAnswers
Service Definitionthe interface, vocabulary types, capability-level constantswhat the capability does
Service Providerone implementationhow it is done here
Consumeruse of the capability — very often a model-facing toolwho needs it

AGENTS.md states the completeness rule: "A capability seam comprises Service Definition / Service Provider / Consumer roles. It is complete, never one role; split only when roles evolve independently."

Both halves of that sentence matter. A seam with a definition and one provider and no consumer is not a seam, it is an interface nobody uses. And splitting roles into separate packages when they do not evolve independently is pure overhead — docs/cookbook/adding-a-package.md says it directly: "A single-purpose plugin stays one package."

Mechanism, walked end to end: `ctx.shell`

Mechanism

The shell capability is the template. From packages/shell/README.md:

PackageRolectx key
shell/Defines the executor contract shared by Providers and Consumersctx.shell
bash-local/Executes commands through the local subprocess serviceregisters ctx.shell
bash-sandbox/Applies the configured sandbox backend before local executionregisters ctx.shell
pwsh-local/Executes PowerShell with Windows-specific process behaviorregisters ctx.shell
shell-env/Provides the managed DSH_* environment shared by shell toolsctx.shellEnv
tool-bash/Exposes Bash execution and background-job integration to the modelregisters on ctx.tools
tool-pwsh/Exposes PowerShell execution to the modelregisters on ctx.tools

The Service Definition. packages/shell/shell/README.md opens by stating the role in one sentence: "The ShellExecutor (ctx.shell) defines WHAT a bash backend does — run foreground commands and start background processes — without saying HOW."

Its API surface:

MemberSemantics
run(spec)Foreground execution. Rejects only for infrastructure failures (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive ShellRunResult.
start(spec)Background execution. Returns a task-free ShellProcess handle immediately; no timeout applies.
sandboxModeThe capability fact for the tool layer: the default mode a sandboxing executor confines under (undefined in the base class).
ShellProcess.readOutput()Incremental output read — consecutive reads never re-deliver.
ShellProcess.kill()Kill the process group. Returns false when it already finished.

Look at what the definition puts in the type rather than in prose. "Rejects only for infrastructure failures" means a nonzero exit is data, not an exception. If that were left to each provider, one would throw on exit 1 and one would not, and the consumer would need provider-specific error handling — which would defeat the seam. The definition's job is to make every provider indistinguishable at the boundary. Everything a consumer might branch on must either be uniform or be a declared capability fact.

sandboxMode is that second kind, and it is the most instructive member here. Some executors sandbox and some do not, and the model-facing tool must advertise escalation parameters only when the composition can honour them. The naive fix is for tool-bash to check whether the provider is dsh-bash-sandbox — which would import a provider from a consumer and destroy the seam. Instead the definition carries a neutral capability fact, and packages/shell/shell/README.md records how the consumer uses it: "dsh-tool-bash reads it at registration to advertise the escalation fields only when the composition honors them."

The Providers. bash-local spawns through ctx.subprocess. bash-sandbox is described as "dsh-bash-local's mechanics with every spawn confined via ctx.sandbox, denials reported as result facts." pwsh-local runs PowerShell. All three subclass ShellExecutor and implement the abstract methods; the definition adds one obligation providers cannot skip: "Disposal must kill every running process and await its exit" — the effect-reversibility clause of M7.1, applied to a resource that would otherwise outlive its plugin.

The Consumers. docs/capability-seams.md lists the direct consumers of ctx.shell: tool-bash, tool-pwsh, hooks-claude-code, and hooks-codex. The hook bridges are consumers because running an external shell hook is shell execution — which is why a hook automatically inherits whatever confinement the composition selected. That is the seam paying a dividend nobody explicitly designed.

The generated note on the row states the payoff:

The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them.

Worked example: slotting in a new provider

Worked example

You want shell commands to run in a remote container. Under the Module 1 spine that is an edit to the dispatch branch, and every caller of shell execution must learn about remoteness. Under the seam:

  1. Create the package. packages/shell/bash-remote/, following docs/cookbook/adding-a-package.md: package.json with private: true, type: module, @deepseek-ai/cordis in both peerDependencies and devDependencies at the same range; a tsconfig.json extending the base with references to the vendored packages and to packages/shell/shell; src/index.ts; a README.md with the gated "Known Limitations and Deferred Work" section.

  2. Subclass the definition. Extend ShellExecutor, implement the abstract methods, honour the contract: infrastructure failures reject, command failures resolve with a result; readOutput() is incremental; disposal kills and awaits. If you sandbox, set sandboxMode; if not, leave it undefined.

  3. Register as an effect in apply(ctx), with inject declaring whatever transport service you need.

  4. Swap the row in composition — one line in a cordis.yml or one patch row (M7.3).

tool-bash, tool-pwsh, and both hook bridges are not recompiled, not edited, and not aware. That is the deliverable of the pattern, and it is worth stating as the acceptance test: if adding a provider requires editing a consumer, you do not have a seam.

Which role does a package get?

docs/cookbook/adding-a-package.md carries a naming table that is one of the more useful artifacts in the repo, because role confusion is where seams rot. A selection:

WordUse it whenDo not use it when
RegistryIt owns a dynamic set of named registrations, including lookup, precedence, lifetime, and disposal.Its main contract is dispatch, execution, or orchestration.
ExecutorIt runs one explicit request or resolved specification in one capability.It owns a broad application lifecycle or provider catalog.
ProviderIt supplies one implementation of a capability definition.It is the capability definition, provider registry, or consumer runtime.
PolicyIt decides what is allowed, selected, limited, or observed.It performs the mechanism the decision permits.

The guidance above the table is the transferable part: "Name the stable current responsibility. Do not name the first implementation, a possible future expansion, or the Cordis base class." A definition package named after its first provider is a seam that will be violated within two releases, because the name tells every reader the abstraction is really about that one implementation.

Tradeoffs

Three packages instead of one is real cost: three manifests, three tsconfigs, three READMEs, and a dependency graph to keep acyclic. The gate is the "evolve independently" clause. The shell trio passes because sandbox strategy, PowerShell support, and tool ergonomics genuinely move on separate schedules. A todo-list plugin does not pass, and dsh keeps it as one package.

The second cost is that the definition becomes the hardest thing to change. Adding a member to ShellExecutor breaks every provider. The mitigation is deliberate minimalism plus capability facts (sandboxMode) rather than capability methods, so a provider that cannot do something reports it rather than being forced to stub it.

How others differ

MCP is a capability seam moved onto a wire protocol. The definition is the MCP spec, providers are servers, consumers are hosts. The trade: process isolation and language independence, paid for with serialization overhead, a coarser type contract, and a much harder story for capability facts — an MCP tool cannot easily tell a host "I sandbox, so offer escalation." dsh treats MCP as one more shape of the same idea: docs/cookbook/extension-cookbook.md maps "MCP → one plugin per server: discover tools → ctx.tools.register()", so MCP tools arrive at the same registry as first-party ones and everything downstream is uniform.

VS Code uses contributes in a manifest plus an activation-event model; the seam is declarative and the extension host is a separate process. Eclipse's OSGi is the ancestor of most of this and shows the long-run cost: extreme replaceability, and a versioning model complex enough that it became the thing people complained about. That is the warning label on M7.8.


Check yourself

2 questions on M7.2

Start drill