# Call classes (/docs/call-classes)



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](/docs/seams) (the entry point each class binds to) and
[Family-lock](/docs/family-lock#the-routing-cluster) (the session-
locked column the seam resolves against). 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; }" />

```typescript
type CallClass = "utility" | "reasoning" | "converse" | "synthesize"
```

## The four classes [#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 [#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](/docs/seams#the-singleton-synthesize-seam).

### reasoning [#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 [#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 [#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 [#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](/docs/seams#the-singleton-synthesize-seam)
for the invariant in full.

## The callClass lint [#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.

```typescript
// 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](/docs/seams) for the four factories.

## callClass on the audit row [#callclass-on-the-audit-row]

Every `AuditableCall` row carries `call.callClass`. Per-turn cost
rollup by class is one `GROUP BY`:

```sql
SELECT call_class, SUM(token_cost)
FROM harness_auditable_calls
WHERE turn_id = $1
GROUP BY call_class
```

A `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](/docs/auditable-call-row) for the full row
shape and the other slices it supports.

## callClass in the fingerprint [#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](/docs/fingerprint) for the full key/metadata split.

## How call class flows through a node [#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.

```typescript
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](/docs/nodes) for the full
node-metadata contract.

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

<Cards>
  <Card title="Seams" href="/docs/seams" description="The four seam factories that carry the callClass literal and the sync stream-observer verdict ladder." />

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

  <Card title="Nodes" href="/docs/nodes" description="The node-metadata contract — stageId, acceptsSeam, subscribes, writes." />

  <Card title="Fingerprint" href="/docs/fingerprint" description="The cache-key tuple — callClass is one of the keyed fields." />
</Cards>
