# Seams (/docs/seams)



Every LLM call in `@pleach/core` routes through exactly one of four
seams. The seam factory carries the `callClass` literal that
downstream code threads as a type parameter, so a node consuming a
seam never re-introduces the literal at the call site. The synthesize
seam is a per-runtime singleton, which means the rendered string and
the audited string are the same string by construction.

A seam is the routing cluster's entry point — it carries the
[CallClass](/docs/call-classes) literal into resolution and
dispatches the result against the session's
[family lock](/docs/family-lock#the-routing-cluster). See
[Family-lock → the routing cluster](/docs/family-lock#the-routing-cluster)
for the cluster framing.

<SourceMeta subpath="@pleach/core" source="{ label: &#x22;src/graph/seams/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/graph/seams&#x22; }" />

## The four seams [#the-four-seams]

| Seam             | File                            | Role                                                                                                                                                                                                                                                  |
| ---------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `synthesizeSeam` | `graph/seams/synthesizeSeam.ts` | User-facing answer; exactly one per turn                                                                                                                                                                                                              |
| `reasoningSeam`  | `graph/seams/reasoningSeam.ts`  | **Reserved/planned** — the holder is initialized per runtime but no node in the default runtime routes through it today; the contract reserves `reasoning` for future answer-sufficiency judging, plan compose/revise, and tool-result interpretation |
| `utilitySeam`    | `graph/seams/utilitySeam.ts`    | Internal classification (intent, planner, cache routing)                                                                                                                                                                                              |
| `converseSeam`   | `graph/seams/converseSeam.ts`   | Short user-facing prose (refusal hints, retry narration)                                                                                                                                                                                              |

A seam IS the per-call-class factory that builds a
`ProviderSeam<C>`. The factory binds the locked `callClass`, the
resolved transport, and the per-runtime stream-observer registry
into one entry point; nodes invoke that entry point without seeing
the underlying provider plumbing. The type parameter `C` carries
the call class through to the return shape — a `synthesizeSeam`
call returns a `ProviderSeamResult<"synthesize">`, distinct at
compile time from a `utilitySeam` result.

A seam is NOT a provider adapter — that's the transport layer
covered in [Providers](/docs/providers). A seam is also NOT model
resolution — the `(family × callClass)` matrix lives in
[Family lock](/docs/family-lock). The seam sits above both: it
holds the call class, calls into resolution, and dispatches the
observer ladder around the resulting stream.

<Callout type="info" title="Coming from LangGraph?">
  LangGraph has no seam concept. A LangGraph node calls a model directly (`await model.invoke(messages)`) and the LLM is just another resource. Pleach factors that call out so the **call class** (`synthesize`, `reasoning`, `utility`, `converse`) is a type-level invariant — a node can't accidentally fire a `synthesize` call during the `tool-loop` stage because the decision node there consumes a `ProviderSeam<"utility">` and the synthesize seam is a different type.

  The translation: where LangGraph's nodes know about the model, pleach's nodes know about the *kind* of model call they need. The seam carries the binding. The benefit: one runtime can route the four call classes to four different models (utility → cheap; synthesize → flagship) without the node knowing.
</Callout>

## The singleton synthesize seam [#the-singleton-synthesize-seam]

The user sees the synthesis. If the runtime fires two synthesize
calls and renders one of them, the audit ledger records one string
and the UI shows a different one. Capping at exactly one synthesize
per turn means the audited string and the rendered string are the
same string.

The cap is enforced by two pieces. `SynthesizeSeamHolder` is a per-
runtime singleton, and the synthesizer node — plus the recovery path
that stands in for it when a provider fails — is the only consumer
that asks the holder for the seam. Nothing else reaches it. The
tool-loop's continuation call, the "call another tool or finish?"
decision, is `utility`-class and consumes a separate utility seam, so
it physically can't bump the synthesize counter. That separation is
what makes the cap structural rather than policed: "exactly one
synthesize per turn" holds because exactly one node class reaches the
synthesize seam, by construction. `TurnSynthesizeCounter` is
idempotent on `messageId`; a second synthesize call for the same
message is a no-op against the counter and never reaches the provider.

`npm run test:graphnoderef-wire-check` is the wire-check that
catches code reaching around the holder. A node that constructs
its own `synthesizeSeam` instead of consuming the shared one fails
the check before CI green. The check walks the compiled graph,
collects every node whose `acceptsSeam` is `"synthesize"`, and
asserts they share one underlying `ProviderSeam<"synthesize">`
identity. See
[Architecture § Call classes](/docs/architecture#2-call-classes)
for the broader invariant the singleton pins down.

## The callClass lint [#the-callclass-lint]

The `callClass` literal — the strings `"utility"`, `"reasoning"`,
`"converse"`, `"synthesize"` — appears only inside the four seam
factories and the matrix module. Outside those files, code resolves
a model through `AgentAdapter.resolveModel<C>()`, and the locked
call class threads through as a type parameter without a literal.

```typescript
import type { CallClass } from "@pleach/core/modelfamily"

// inside a node — no "synthesize" literal at the call site
async function synthesizer<C extends CallClass>(
  ctx: NodeContext<C>,
): Promise<Partial<State>> {
  const model = await ctx.adapter.resolveModel<C>()
  return { /* ... */ }
}
```

`npm run lint:callclass-literals` is the CI gate. A literal that
escapes the seam factories fails the lint. See
[Architecture § Call classes](/docs/architecture#2-call-classes)
for the rationale and [Call classes](/docs/call-classes) for the
four-class taxonomy.

## Stream-observer ladder [#stream-observer-ladder]

Each seam dispatches a per-chunk observer ladder on the inbound
stream. For every chunk, every registered observer returns one
verdict:

| Verdict    | Effect                                                                                                 |
| ---------- | ------------------------------------------------------------------------------------------------------ |
| `continue` | No-op; pass the chunk through                                                                          |
| `amend`    | Replace the chunk content 1:1 — strict, no multiplex                                                   |
| `emit`     | Forward the chunk verbatim AND emit a named-channel envelope                                           |
| `stop`     | Stop the stream; the downstream consumer reads the stop sentinel                                       |
| `buffer`   | Hold the chunk; the seam suppresses the yield and accumulates state                                    |
| `release`  | Flush previously-buffered chunks in order; optionally strip N chars from the seam's accumulated mirror |

Observers register through `HarnessPlugin.contributeStreamObservers`.
The seam queries the per-runtime `StreamObserverRegistry` at
invocation time and invokes each observer's `onChunk(chunk, ctx)`
in registration order. See [Plugin contract](/docs/plugin-contract)
for the registration shape and
[Stream events](/docs/stream-events) for how verdicts surface to
the consumer.

`onChunk` is sync only — no `Promise<ObserverChunkVerdict>`
overload. Two async observers resolving in a different order on
replay than on record would race; the runtime would then schedule
a different next superstep and the diff harness would flag the
divergence. Keeping the verdict sync is the armor that holds the
replay-determinism property.

`amend` being 1:1 is the matching armor on the chunk side. An
observer that returned multiple chunks for one input would break
byte-replay: the chunk count itself is part of the recorded
stream. Plugins that need fan-out emit named-channel envelopes via
the `emit` verdict — the main stream stays untouched, the side
channel carries the structured side-effect.

The `buffer` / `release` pair is the additive extension for
observers that need to hold chunks until a boundary settles —
e.g. an early-coherence observer that buffers until it can prove
the prefix is clean, then releases the held chunks in order.
`release` carries an optional `stripAccumulated` count so the
seam trims its own accumulated-content mirror; the outer consumer
loops subscribe to that envelope to keep their mirrors in sync.

## Plugin observers are canonical [#plugin-observers-are-canonical]

The chunk-time detection surface lives in plugin observers, not
in the seam itself. Hosts register factories via
`HarnessPlugin.contributeStreamObservers`; the per-runtime
`StreamObserverRegistry` collects them at construction, and the
seam queries the registry at invocation time and dispatches every
chunk through the verdict ladder above.

A reference host ships observers as a worked example — force-
synthesis echo, same-tool repetition, markup sanitization, halluci-
nated tool id, plus emit-only detectors for phrase-loop, fim-
token, substring-repetition, prefix-garble, early-coherence, and
body-garble. `@pleach/core` provides the contract and the
registry; every host ships its own observers.

The historical helper-barrel dispatchers in `runDecisionBody`
(`dispatchHallucinationDetector`, `dispatchPhraseLoopDetector`,
`dispatchFimTokenDetector`, `dispatchSubstringRepetition`,
`dispatchPrefixGarbleDetector`, `dispatchEarlyCoherenceDetector`,
`dispatchDsmlDetector`) retired in the Track 3 cutover. Plugin
observers carry the same predicates and route their verdicts onto
named channels; the flag-router consumer maps each channel back
into the same graph-state fields the dispatchers wrote.

The body-garble dispatcher break-path is the deliberate exception
and stays in `createLlmDecisionNode.ts`. The predicate is time-
dependent — it needs mid-stream chunks to settle into legitimate
technical notation, CJK, and foreign-language compound names before
it can distinguish garble from valid content. A stop-at-chunk verdict
can't carry that asymmetry; an observer that fired at the wrong
moment would false-positive on every long technical token. The kept
dispatcher is the regression-detection armor against that class
of false positive, and the asymmetric close is the documented
shape — chunk-time observers for everything time-invariant, the
inline dispatcher for the one time-dependent case.

## How a node consumes a seam [#how-a-node-consumes-a-seam]

A node declares which seam it consumes via `acceptsSeam` in its
metadata. The field is `CallClass | null` — a call-class literal
for nodes that reserve a seam, `null` for pure state transforms
and deterministic anchor builders that take no LLM call. The
reservation is the seam-attachment contract; future LLM growth
attaches without re-typing the node signature. The substrate
threads the bound seam into the node's signature, and the node
never imports a seam factory directly.

```typescript
const toolLoopMetadata = {
  stageId: "tool-loop",
  acceptsSeam: "reasoning",
  subscribes: ["messages"],
  writes: ["messages"],
}

async function toolLoop(state, ctx) {
  // ctx.seam is the bound reasoningSeam — no factory import
  const result = await ctx.seam.invoke({ messages: state.messages })
  return { messages: [...state.messages, result.message] }
}

graph.addNode("toolLoop", toolLoop, toolLoopMetadata)
```

`npm run lint:harness-boundary` is the CI gate. A node that imports
from `src/graph/seams/` fails the lint — the bound seam on the
node context is the only path. See [Nodes](/docs/nodes) for the
full metadata shape and [Graph](/docs/graph) for how the substrate
wires the binding.

## Determinism contract [#determinism-contract]

Seams compose with the rest of the determinism story. The observer
dispatch is sync, so the verdict ladder produces the same output
shape on replay as on record. The call returns a stable
`ProviderSeamResult<C>` whose fields are typed by call class. The
provider response feeds channel reducers that are commutative and
associative, so concurrent writes from a fan-out resolve the same
way on every replay.

The byte-replay property — that a recorded turn replayed against
the same package version + the same input produces a byte-identical
fingerprint stream — depends on every link in this chain. See
[Determinism](/docs/determinism) for the full five-contract set.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Call classes" href="/docs/call-classes" description="The four-class taxonomy — utility, reasoning, converse, synthesize — and per-turn budgets." />

  <Card title="Family lock" href="/docs/family-lock" description="The (family × callClass) matrix the seam resolves against and the family-strict cascade." />

  <Card title="Providers" href="/docs/providers" description="The transport layer beneath the seam — native, openrouter, byok-native, byok-openrouter." />

  <Card title="Architecture" href="/docs/architecture" description="Where seams sit in the substrate — stage lattice, call classes, audit ledger, event log." />
</Cards>
