M1.6
Reading the capability seam
1,043 words · about 7 min
Concept
A capability seam is the boundary where model-agnostic core logic stops and
vendor-specific code begins. Reading a seam correctly is the skill that lets you
open an unfamiliar harness and predict what changes when you swap a provider. In
dsh the term is precise — from docs/glossary.md and AGENTS.md, a seam
comprises three roles:
- Service Definition — owns the
ctx.<key>and the vocabulary types. It is an abstract class or a concrete registry, deliberately never a TypeScriptinterface, because the definition must exist at runtime to be registered. - Service Provider — one or more implementations.
- Consumer — the code that calls the service through the key.
The rule stated in AGENTS.md is: "A capability seam comprises Service
Definition / Service Provider / Consumer roles. It is complete, never one role."
Reviewing only the provider misses the seam.
Mechanism
For the model seam, docs/capability-seams.md gives the whole census in one row:
| Key | Owner | Implementations | Direct consumers |
|---|---|---|---|
ctx.llm | packages/llm/llm | llm-deepseek, llm-pi-ai, llm-replay | agent-loop, compaction-basic |
Everything about the boundary follows from that row. The definition is
LlmRuntime in packages/llm/llm/src/index.ts, a Service that owns the key:
export class LlmRuntime extends Service {
constructor(ctx: Context) { super(ctx, 'llm') }
registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
}The provider contract is one abstract class in the same file, and it has exactly one required method:
export abstract class LlmAdapter {
providerInfo(provider: string): LlmProviderInfo
listModels(_provider: string): Promise<readonly LlmModelInfo[]>
resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo>
/** Stream one model call as raw chunks. The only required method. */
abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>
}That is the seam in one sentence: an adapter's entire obligation is to turn
GenerateOptions into an AsyncIterable<StreamChunk>. The module docstring on
packages/llm/llm/src/types.ts states the exclusivity — "Adapters alone
translate provider wire messages" — and everything upstream (the loop, the log,
the UI, compaction) is written against the neutral vocabulary.
Registration is an effect, per the AGENTS.md convention "Registrations are
effects": registerAdapter returns a handle that is also its own disposer, so
unloading a plugin withdraws its routes. The failure prevented is a stale
registration surviving a hot reload and serving requests from dead code — which
is also why registerAdapter throws DUPLICATE_ADAPTER all-or-nothing if any
route is taken, rather than half-registering.
Worked example: what crosses, and what does not
Worked exampleRead LlmRuntime's adapter dispatch and you can see the core doing work an
adapter must never do:
-
Selecting the adapter. A private
registration(provider)lookup throwsNO_ADAPTERif none is registered. Routing is core, not vendor. -
Turning throws into protocol. Adapter selection, dispatch, and iteration failures are converted into a terminal
finishchunk:function adapterFailureChunk(error: unknown, signal?: AbortSignal): StreamChunk { const failure = normalizeLlmFailure(error) return { type: 'finish', reason: signal?.aborted || failure.code === 'ABORTED' ? { kind: 'aborted', failure } : { kind: 'error', failure }, } }The consumer therefore only ever handles one shape — a stream that always ends in
finish— instead of a mixture of streams and exceptions. That is the failure this design prevents: half the loop's error handling collapsing into the stream protocol and the other half intotry/catch. Note the deliberate asymmetry documented onstream(): adapter failures become chunks, while "middleware, nested-call, cleanup, and consumer failures remain thrown" — a bug in your listener must not be disguised as a provider outage. -
Interception.
stream()does not call the adapter directly; it goes throughctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(...)). Retry, replay, and routing plugins register on that waterfall and either callnext()to reach the adapter or yield their own chunks.packages/llm/llm-retryis a real consumer of this hook. The seam is thus interceptable without being editable — new behaviour arrives as a plugin, per "Plugins, not loop changes". -
Immutability of loop-built requests. The
llm/streamJSDoc says a loop-built request "arrives deep-frozen (mutation throws): its content is a pure function of the session log … so listeners read it, never rewrite it." The failure prevented is a middleware silently mutating history so the transcript no longer reconstructs the request that was actually sent.
Note also where ToolSchema is declared. Its JSDoc in types.ts explains that
it lives in dsh-llm and not in the tools package "because it is part of
GenerateOptions". Seam placement follows who must agree on the type, not
topical tidiness — the model request and the adapter must agree on tool-schema
shape, so the type belongs to the model seam.
Tradeoffs
A narrow adapter contract (one stream method) makes adapters trivial to write
and audit, and pushes complexity into the core, where an adapter cannot express
something genuinely provider-specific without widening the shared vocabulary.
dsh accepts that friction deliberately: widening the union is a reviewed
change, whereas per-adapter escape hatches are invisible. The escape valve that
exists — replayState on assistant messages, described in
packages/llm/llm/src/message.ts as "lossless-JSON adapter state needed to
replay the provider response" — is tightly constrained: LlmRuntime exposes it
to a target adapter "only when that adapter instance currently owns both this
historical provider and the target provider," so provider-private state cannot
leak across a provider swap.
How others differ
Vercel's AI SDK draws a comparable seam with a versioned language-model provider interface and normalised stream parts, and has repeatedly had to revise that interface as capabilities grew — evidence that this seam is genuinely hard to get right, not a solved problem. Frameworks that pass raw provider responses through have no seam at all, and pay for it with vendor conditionals scattered through application code.
How to read a seam in an unfamiliar codebase
A repeatable procedure:
- Find the definition: search for the service key (
ctx.llm) or the abstract base class. Read its exported types — that is the vocabulary. - Find the providers: search for the registration call
(
registerAdapter,register,provide). Each hit is one implementation. - Find the consumers: search for uses of the key. If a consumer imports anything from a provider package, the seam is leaking.
- Ask the swap question: if I registered a different provider, what else would have to change? The correct answer is "nothing". Anything else is your bug list.
Check yourself
4 questions on M1.6