Call classes
The four-class taxonomy every LLM call declares — utility, reasoning, converse, synthesize — with the lint that restricts the literal and the structural cap of one synthesize per turn.
Every LLM call in @pleach/core declares one of four call classes.
The declared class drives three things: which seam carries the
invocation, the per-turn allotment that applies, and the audit-row
slice the call lands in. The literal is lint-restricted to the four
seam factories, so a tool-loop node can't accidentally fire a
synthesize call and break the rendered-equals-audited invariant.
The taxonomy is small on purpose. Four buckets are enough to distinguish "the runtime is making a routing decision" from "the user is reading this string," and that distinction is what the cost rollup, the matrix routing, and the singleton cap each key off.
CallClass is one of three concepts in the routing cluster — paired with Seams (the entry point each class binds to) and Family-lock (the session- locked column the seam resolves against). See Family-lock → the routing cluster for the cluster framing.
@pleach/coreSourcesrc/graph/seams/type CallClass = "utility" | "reasoning" | "converse" | "synthesize"The four classes
| Class | Purpose | Per-turn allotment | Typical models |
|---|---|---|---|
utility | Internal classification (tool-loop "call another tool or finish?" decisions, intent detection, planner routing, cache lookups) | Unbounded | Cheap / fast |
reasoning | Internal generator feeding the next call (planner expansion, decomposition) | Unbounded | Mid-tier |
converse | Short user-facing prose (refusal hints, retry narration) | Bounded | Mid-tier |
synthesize | The final user-facing answer | Exactly one terminal per turn (a thin/empty first draft may be retried; exactly one row is marked terminal) | Capable |
utility
Internal classification calls. The runtime fires these to pick a branch, not to talk to the user. The canonical one is the tool-loop's continuation decision — "call another tool or finish?" — which runs on every step of an agentic turn. Other typical jobs: intent detection, planner routing, cache-bucket selection, tool-arg validation. The output is consumed by the graph, never rendered. Per-turn count is unbounded — a tool loop can fire as many utility calls as it needs to reach a decision. The matrix routes utility to the cheapest cell in the locked family.
Keeping the continuation decision in utility is what makes the
synthesize cap structural: a utility call consumes a different seam
and can't reach the synthesize counter, so the only call that can land
the final answer is the synthesizer. See
Seams — the singleton synthesize seam.
reasoning
Internal generators feeding the next call. The output is intermediate
text that another node will consume — a plan expansion, a
decomposition, an intermediate summarisation. Like utility, the
user never sees it; unlike utility, the call expects coherent prose,
so the matrix routes it to a mid-tier rung. Unbounded per turn.
converse
Short user-facing prose that is not the synthesis. Refusal hints, retry narration, "I need to clarify…" interstitials. The reader sees the output, so the matrix routes to a mid-tier model that can hold a voice, but the per-turn count is bounded — a turn that fires four converse messages before a final answer is broken, not chatty.
synthesize
The final user-facing answer. Exactly one terminal synthesis per turn — the one row that lands the answer the user sees. The matrix routes to a capable rung; the constraint is structural, not advisory.
A turn MAY make more than one synthesize call: if the first draft
comes back thin or empty, the synthesizer fires a bounded, in-stage
recovery retry. Each call records its own append-only audit row, but
at most one row carries synthesisQuality.terminalSynthesis: true
— the final answer. It is exactly one on a synthesized turn, and zero
on a passthrough that accepts the model's first draft as-is. The
invariant is therefore count(terminalSynthesis === true) <= 1
(never two), not "one synthesize call."
Why exactly one terminal synthesis per turn
The user sees the synthesis. If the runtime marked two rows terminal and rendered one of them, the ledger would say one thing and the UI another. Marking exactly one row terminal means the rendered string and the terminal-audited string are the same string — even when a thin first draft was retried.
The constraint is enforced by SynthesizeSeamHolder (per-runtime
singleton) and TurnSynthesizeCounter (idempotent on messageId),
and recorded on the ledger by synthesisQuality.terminalSynthesis. A
recovery retry for a thin/empty draft DOES append its own audit row —
the ledger is append-only — but only the row that lands the final
answer is flagged terminal. An append-only audit can verify the
invariant structurally: count(terminalSynthesis === true) <= 1
(exactly one on a synthesized turn, zero on a passthrough, never two).
A consumer (or the dev harness) asserts it with an
atMostOneTerminalSynthesis check that counts terminal-marked rows,
not raw synthesize rows.
See Seams — the singleton synthesize seam for the invariant in full.
The callClass lint
The literal strings "utility", "reasoning", "converse",
"synthesize" are restricted to the four seam factories in
src/graph/seams/. Anywhere else in the codebase, callClass: "..."
fails the lint gate lint:callclass-literals.
Outside the seams, code reaches a model through
AgentAdapter.resolveModel<C>() — the locked class travels as a type
parameter, not a runtime string.
// Inside a seam factory — allowed:
export function synthesizeSeam(/* ... */): ProviderSeam<"synthesize"> {
return makeSeam({ callClass: "synthesize", /* ... */ })
}
// Inside a node — fails lint:callclass-literals:
const result = await adapter.resolveModel({ callClass: "synthesize" })
// Inside a node — passes:
const result = await adapter.resolveModel<C>()Why a lint, not a runtime check? A runtime string lets any node
accidentally pick synthesize and slip past the singleton seam, and
the structural cap goes from "guaranteed by construction" to "hopefully
nothing reached around it." The lint catches the escape at the seam
boundary. See Seams for the four factories.
callClass on the audit row
Every AuditableCall row carries call.callClass. Per-turn cost
rollup by class is one GROUP BY:
SELECT call_class, SUM(token_cost)
FROM harness_auditable_calls
WHERE turn_id = $1
GROUP BY call_classA synthesize-class row that fires twice in the same turnId is the
canonical "the cap leaked" alert — the query is one predicate. See
The AuditableCall row for the full row
shape and the other slices it supports.
callClass in the fingerprint
callClass participates in the cache key. Different classes resolve
to different models in the matrix, so a utility call and a
synthesize call on otherwise-identical inputs sit in different
equivalence classes — the cache scope reflects the routing.
Concretely: replaying a utility call doesn't return a cached
synthesize result that happened to share the same prompt. The
class is part of the equivalence class, so the cache only collapses
calls the matrix would route the same way.
See Fingerprint for the full key/metadata split.
How call class flows through a node
A node declares acceptsSeam: CallClass | null in its metadata. The
substrate binds the matching seam at compile time and hands the node
a ProviderSeam<C> where C is the declared class. Outside the
substrate, the literal never appears.
const synthesizerMeta = {
stageId: "synthesize",
acceptsSeam: "synthesize",
subscribes: ["plan", "toolResults"],
writes: ["finalMessage"],
}A node with acceptsSeam: null is a seam-free state transform — no
LLM call, no class to declare. See Nodes for the full
node-metadata contract.
Where to go next
Seams
The four seam factories that carry the callClass literal and the sync stream-observer verdict ladder.
Family lock
The (family × callClass) matrix the seam resolves against and the family-strict cascade.
Nodes
The node-metadata contract — stageId, acceptsSeam, subscribes, writes.
Fingerprint
The cache-key tuple — callClass is one of the keyed fields.
Seams
The per-call-class provider entry point — four seam factories, the singleton synthesize seam invariant, and the sync stream-observer verdict ladder.
Family-locked routing
One ProviderFamily and one Transport lock at session start, freezing tokenizer, prompt-cache key, tool-call dialect, and refusal pattern. The cascade walks in-family rungs only.