Certified Agent Harness Engineer
Course sections

M7.1

Why a plugin architecture, and what the seam must promise

1,579 words · about 10 min

The failure the Module 1 spine cannot avoid

Recall the spine's shape. A main() builds a tool table, a system prompt, and a client; a while loop calls the model, dispatches tool calls, and appends results. Adding web search means editing the tool table. Adding a permission prompt means editing the dispatch branch. Adding a second model provider means editing the client construction. Every one of those edits is in the same file, and the file is yours.

Three failures follow, and they are worth naming precisely because "monoliths are bad" is a vibe, not a mechanism.

Fork divergence. A third party who wants a capability you rejected — or simply have not gotten to — must copy your loop. From that moment your bug fixes do not reach them and their capability does not reach you. The cost compounds with every release. This is the failure that kills ecosystems: not that extension is impossible, but that extension is only possible by forking.

Untestable coupling. When the permission check lives inside the dispatch branch, you cannot test dispatch without permissions or permissions without dispatch. Everything you might want to vary independently is welded to everything else.

Unbounded blast radius on swap. Swapping local shell execution for sandboxed execution touches the dispatch branch, which touches the tool table, which touches prompt assembly. There is no line in the program at which you can say "below here, nothing knows whether execution is local."

A plugin architecture is the claim that you can draw that line. It is not free, and Section M7.1's tradeoffs below are honest about the bill.

The seam contract

dsh states its thesis in the first line of AGENTS.md:

DeepSeek Harness is a plugin-based agent harness on vendored Cordis: everything is a plugin.

docs/architecture.md makes the consequence explicit:

Every part of the product is a plugin, including the model adapter, the tool registry, the session log, and the agent loop itself, so every part is replaceable from configuration. There is no privileged core to patch: you extend dsh by mounting a plugin beside the others, and registrations are effects that unwind when their plugin unloads.

That paragraph contains the whole contract, and it has four clauses worth separating.

1. Uniformity. If some parts are plugins and some are core, the boundary between them is where every argument happens — "should this be in core?" becomes a permanent political question, and the core accretes whatever won the last argument. Making the loop itself a plugin removes the privileged position. docs/capability-seams.md classifies ctx.agentLoop with the role bundle and notes: "The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package." Extensions depend on the loop's events, not on the loop.

2. Named, stable attachment points. A plugin is useless without somewhere to attach. In dsh, a plugin claims a stable ctx.<key> service, listens on typed events, or both. docs/architecture.md carries a "Where new behavior goes" table that is effectively the index of attachment points — add a model provider on ctx.llm, a model-facing capability on ctx.tools, a human command on ctx.commands, a shell backend on ctx.shell, an interception on the relevant agent/* or tools/* event.

3. Registration as reversible effect. The convention in AGENTS.md is blunt: "Registrations are effects: every contribution goes through ctx.effect() / ctx.on(); a registry's register() returns the disposer." This is the clause people skip, and it is the one that makes the rest real. If unmounting a plugin leaves its tool in the registry, its listener on the event bus, and its prompt section in the assembly, then plugins are only additive and "replaceable from configuration" is a lie. Reversibility is what buys hot reload, per-session composition, and clean subagent teardown — docs/cookbook/extension-cookbook.md lists "Plugin hot-reload → every registration is a ctx.effect → vendored HMR just works," which is not a feature anybody built; it is a consequence of the discipline.

4. Declared dependency, not ordered boot. Cordis expresses load order as service requirements via a plugin's inject field rather than a manual boot sequence (docs/cordis-primer.md). A plugin says what it needs; the framework decides when it can run. The failure this prevents is the boot-order bug: a 400-line main() where moving one initialization line breaks something 200 lines away for reasons nobody can reconstruct.

Mechanism: Cordis in the five ideas that matter here

Mechanism

docs/cordis-primer.md — read it; it is the framework contract and this module does not replace it — frames Cordis as five ideas:

  1. A plugin is an object implementing Service: a function with optional inject and apply(ctx) fields, or a Service subclass.
  2. A context is a repository of services, each claiming a stable ctx.<key>.
  3. Dependency is declared via inject.
  4. Events are typed through TypeScript declaration merging.
  5. Registrations are reversible effects via ctx.effect() / ctx.on().

The minimal plugin, from docs/cookbook/extension-cookbook.md:

import type { Context } from '@deepseek-ai/cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'

declare function isAllowed(exec: ToolExecution): Promise<boolean>

export const name = 'permission-gate'

export function apply(ctx: Context) {
  ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
    if (!(await isAllowed(exec))) {
      return { kind: 'deny', reason: 'Denied by policy.' }
    }
    return next()
  })
}

That is a complete, working permission system as far as the architecture is concerned. It imports no core module, edits no loop, and unmounts cleanly because ctx.on is an effect. Note the next(): tools/pre-execute is a waterfall, and AGENTS.md is emphatic — "Waterfall listeners MUST call next() to delegate; returning without it short-circuits the chain." Returning a decision without calling next() is not a bug in this snippet; it is the deny. The same syntax that expresses "I have decided" and "I defer" differing only by whether next() was called is the single most common source of extension bugs in this style of framework. Section "Common pitfalls" returns to it.

Dispatch modes are part of the public contract

An extension point's mode determines what a listener can do, and dsh treats it as contract, documented with an @mode JSDoc tag (AGENTS.md). From docs/cordis-primer.md:

ModeAwaitedOrderReturns
emitnoregistration orderno
waterfallnoregistration orderyes
parallelyesall in parallelno
serialyesregistration orderyes

The practical reading: only a waterfall or serial point can veto anything. An emit point is observation. This is why agent/session-start is emit and a SessionStart hook "cannot block" (packages/hooks/hooks-claude-code/README.md), while tools/pre-execute is a waterfall and a PreToolUse hook can deny. When you design your own extension point, choosing the mode is choosing whether extension authors get a veto — and once published, changing it breaks every listener.

Tradeoffs — the honest bill

Tradeoffs

Plugin architecture is not free, and a certification that pretends otherwise is training people to over-apply it.

  • Indirection tax on reading. In the Module 1 spine you read the loop and know what happens. In dsh you read the loop and know what happens plus whatever fourteen listeners on agent/pre-step do. docs/event-producer-consumer.md exists precisely because that question stopped being answerable by reading. Generated maps are a mitigation, not a cure.
  • Ordering becomes emergent. Waterfall listeners run in registration order, which is composition order, which is a YAML file. Behaviour now depends on a config file's line order. dsh accepts this and gives prepend: true for listeners that must run first.
  • Every seam is a compatibility promise. A published ctx.<key> interface with third-party providers cannot change shape without breaking them. Sections M7.8 covers what dsh currently promises (less than you might assume, and deliberately).
  • Debuggability. Stack traces cross framework frames. A denied tool call names the decision, not necessarily the plugin that made it, unless the plugin says so.

The judgement: plugin architecture pays when you have multiple independent authors or must vary a capability per deployment. A single-team harness with one deployment shape is genuinely better off with the Module 1 spine plus a couple of interfaces. Do not cargo-cult the seam.

How others differ

Claude Code extends through several narrower surfaces rather than one uniform one: MCP servers for tools, hooks.json for lifecycle interception, skills for instructions, subagent definitions, and slash commands. Nothing exposes the internal loop, and there is no user-mountable in-process plugin that can register a service the core resolves. That trades away the ability to replace a core component — you cannot swap Claude Code's session store — for a dramatically smaller compatibility surface and a much easier security story: every extension surface has its own hardened boundary, and none of them hand you the process.

OpenAI Codex similarly extends via MCP and a hook config, with a deliberately smaller hook-point set. Aider and much of the open-source cohort take the fork-or-PR path: extension means contributing upstream. That maximises coherence and minimises the compatibility surface at the cost of the ecosystem never developing independently.

dsh sits at the far end of the spectrum: maximum replaceability, maximum compatibility surface, and a security stance that treats composition as trusted (packages/preset/agent-presets/README.md: "a preset is exactly as privileged as the plugins it names"). That is a coherent choice for a reference implementation and a research harness. It would be an aggressive choice for a consumer product, and the difference is not that one team is smarter — it is that the products have different threat models.


Check yourself

4 questions on M7.1

Start drill