---
url: 'https://adk.nht.io/batteries/llm/shared-contract.md'
description: >-
  What LiteRT-LM and transformers.js have in common: the chat_common layer.
  Three pillars — a text parser layer, a portable generation vocabulary, and a
  normalized lifecycle hook surface — written once so the two text-out batteries
  behave identically across two different runtimes. Plus the rule that the
  parser never vetoes a tool call.
---

# The Shared Contract

## LLM summary — the shared `chat_common` contract

* LiteRT-LM and transformers.js are different runtimes (ONNX vs Google `.litertlm`) but share a deliberate `chat_common` contract so the runtime-agnostic parts of "run a local model turn" are written once. **3 pillars:**
* **(1) Parser layer.** Tool calls + reasoning come back as family-specific TEXT, parsed out by `toolCallParser`/`reasoningParser` (default `'auto'` → try-all, first confident match). Families: `hermes`/`gemma`/`gpt_oss`/`phi`/`pythonic`/`llama3_json`/`mistral`/`qwen3_coder`. Written against REAL generations, not tokenizer templates.
* **(2) Portable generation vocabulary.** Canonical [`ChatGenerationOptions`](https://adk.nht.io/api/@nhtio/adk/batteries/llm/transformers_js/helpers/interfaces/ChatGenerationOptions) (`maxTokens`/`sampler`/`temperature`/`topK`/`topP`/`seed`/`enableThinking`/`multimodal`), CANONICAL-WINS over each battery's native field (transformers.js `maxNewTokens`, LiteRT `maxOutputTokens`/`samplerParams`). Identical reproducibility-first defaults (`sampler:'greedy'`, `enableThinking:false`).
* **(3) Lifecycle hooks.** Normalized [`BatteryLifecycleHooks`](https://adk.nht.io/api/@nhtio/adk/batteries/llm/transformers_js/helpers/interfaces/BatteryLifecycleHooks): `onLifecycle` firehose + per-phase `onLoading`→`onCompiling`→`onReady`→`onGenerating`→`onComplete`/`onError`, each handed a `BatteryLifecycleReport`. Opt-in + additive; a throwing hook can't abort a load or turn. Shared with WebLLM + the embeddings battery too (only this pillar; the other two are text-out-only).
* **Authorization rule:** a parser SURFACES every tool call the model emits — including a call to a nonexistent tool. It does NOT silently drop the unknown one; the dispatch layer replies "Tool not found: … Available tools: …" and the model self-corrects. Authorization is the consumer's job in dispatch, not a veto buried in string parsing.

The [overview](./) ends the wire-format-vs-native split on a bleak note: the two on-device batteries hand back
text, and "there is no structured-output contract — there is a decoder and a hope." This page is where that
hope stops being hope.

LiteRT-LM and transformers.js are two genuinely different runtimes — ONNX on one side, Google's `.litertlm`
engine on the other — but they are **not** two unrelated batteries that happen to both emit text. They share a
deliberate common contract, the `chat_common` layer, so that the parts of "run a local model turn" that have
nothing to do with the runtime are written once and behave identically across both. It has three pillars: the
**parser layer**, a **portable generation vocabulary**, and a **lifecycle hook surface**. (The wire-format
batteries reuse parts of this where it makes sense — WebLLM and the embeddings battery share the lifecycle
surface — but the full three-pillar contract is what makes LiteRT-LM and transformers.js interchangeable.)

## Pillar 1 — the parser layer

The shared parser layer knows the real emitted formats of the small open-weight families — `hermes`, `gemma`,
`gpt_oss`, `phi`, `pythonic`, `llama3_json`, `mistral`, `qwen3_coder` — plus an `auto` driver that tries them
in a fixed precedence and takes the first confident match (the exact order is in
[Assembly → LLM batteries](/assembly/batteries-llm)). Both `toolCallParser` and `reasoningParser` default to
`'auto'`, so the common case needs no configuration. The parsers were not written by reading tokenizer
templates — they were written against *real generations*, because the template tells you what the model
trained on and the decoder tells you what actually comes out, and those are not the same string. (That
archaeology, and the times it bit us, is the [on-device build showcase](/showcase/building-on-device-batteries).)

## Pillar 2 — a portable generation vocabulary

Both runtimes have their own spelling for the same knobs: transformers.js calls the output budget
`maxNewTokens`, LiteRT calls it `maxOutputTokens`; LiteRT's sampler is a numeric enum, transformers.js's is
something else again. You should not have to relearn that to swap one battery for the other. So both accept a
single canonical [`ChatGenerationOptions`](https://adk.nht.io/api/@nhtio/adk/batteries/llm/transformers_js/helpers/interfaces/ChatGenerationOptions) vocabulary — `maxTokens`, `sampler`
(`'greedy'`|`'top-k'`|`'top-p'`), `temperature`, `topK`, `topP`, `seed`, `enableThinking`, and
`multimodal: { image, audio }` — and each adapter maps it onto its own runtime API. The precedence is
**canonical-wins**: if you set both `maxTokens` and the native `maxNewTokens`, `maxTokens` is honored and the
native field is the fallback consulted only when the canonical one is absent. The defaults are identical
across both batteries and chosen for reproducibility — `sampler: 'greedy'`, `enableThinking: false` (many
reasoning templates default thinking *on* and burn your token budget before the answer; this turns it off
unless you ask). Prefer the canonical names; the native fields still work, but portable config is the point.

## Pillar 3 — a lifecycle hook surface

On-device batteries spend real wall-clock time *before the first token*: pulling weights (a `.litertlm` model
is ~2 GB) and then booting the WebGPU/wasm runtime — engine creation and the opaque shader/graph compilation
that is often the slowest part of a cold start. The shared layer normalizes that into one opt-in
[`BatteryLifecycleHooks`](https://adk.nht.io/api/@nhtio/adk/batteries/llm/transformers_js/helpers/interfaces/BatteryLifecycleHooks) surface across all of the on-device batteries: an `onLifecycle` firehose plus
per-phase hooks (`onLoading` → `onCompiling` → `onReady` → `onGenerating` → `onComplete`, or `onError`), each
handed a normalized `BatteryLifecycleReport` with `progress` in `0..1` during `loading` when the provider
reports it. It is purely additive — omit the hooks and behavior is byte-for-byte unchanged — and a throwing
hook can never abort a load or a turn. The full report shape and the per-phase semantics are in
[Assembly → LLM batteries](/assembly/batteries-llm#lifecycle-boot-progress).

## Pillar 4 — raw-generation observability

The parser layer is the seam between *what the model emitted* and *what the battery extracted* — and on a
small on-device model those two diverge more than you would like. A 2B emits a tool call in a shape no parser
recognizes, the call silently falls through into the assistant prose, and the symptom you see is "the agent
ignored its tools" or "it abstained on something that is clearly documented." The cause is invisible unless you
can see the raw text the parser actually received.

So both on-device batteries (transformers.js and LiteRT-LM) expose an opt-in
[`RawGenerationObserverFn`](https://adk.nht.io/api/@nhtio/adk/batteries/type-aliases/RawGenerationObserverFn) via `onRawGeneration`. It fires once per completed generation — after
envelope-token stripping and reasoning/tool-call parsing, but before anything is persisted — with the model's
complete `rawText`, the residual `cleanedText` that becomes the assistant message, and the extracted
`reasoning` / `toolCalls`. A call the model made but the parser declined shows up as a non-empty `rawText`
whose `toolCalls` is empty and whose text is still sitting in `cleanedText`: the leak, made visible.

```ts
new TransformersJsAdapter({
  model: 'onnx-community/gemma-4-E2B-it-ONNX',
  onRawGeneration: ({ rawText, cleanedText, toolCalls }) => {
    if (toolCalls.length === 0 && /\bcall:|<\|tool_call>/.test(rawText)) {
      console.warn('model emitted a tool call the parser did not catch:', rawText)
    }
  },
})
```

Like every other hook here it is purely observational — the return value is ignored, a throwing observer can
never corrupt the generation path, and omitting it is byte-for-byte unchanged. It is the supported seam for
bringing a parser up against a new model, capturing ground-truth fixtures, and answering "why did it do that?"
live — the job that otherwise tempts you into patching a temporary hook into the adapter.

## The same leak on the native-API batteries — `localToolCallParser`

The on-device batteries parse tool calls out of text unconditionally, because those runtimes never return a
structured call — the parser layer *is* how a call is found. The **native-API batteries** (Ollama,
OpenAI-compatible Chat Completions, and WebLLM) are the other way around: the provider parses tool calls
server-side and hands back a structured `message.tool_calls`, so those batteries trust it and do not text-parse.

That is correct until you point a small model at one of them. Gemma, Phi, and friends routinely emit a call in
a surface form the provider's chat template does not recognize — `<call:name{…}`, a fenced ` ```json `
block, `<tool_code…>`, bare `name\nkey: value` — and when the template misses it the call lands in plain
`content` with `tool_calls` empty. Same leak as Pillar 4, different runtime: the agent looks like it "ignored
its tools." This is a cross-model, cross-weight reality, not one provider's quirk.

So the native-API batteries take an opt-in `localToolCallParser` (`'auto'`, a family name like `'gemma'`, or a
custom [`ToolCallParserFn`](https://adk.nht.io/api/@nhtio/adk/batteries/llm/transformers_js/helpers/type-aliases/ToolCallParserFn) — the exact same `chat_common` parser layer). It is consulted **only** when the
provider returned zero structured calls, so native tool-calling always wins; recovered calls execute exactly
like native ones and show up on `onRawGeneration`. Absent = disabled = today's native-only behaviour, byte-for-
byte. It is the native-side mirror of the on-device `toolCallParser` — the difference is that on-device it is
always on (there is nothing else), and on the native path it is a fallback behind the provider's own parse.

```ts
new OllamaAdapter({
  model: 'gemma4:e2b-it-qat',
  // recover a call the Ollama template did not lift into message.tool_calls
  localToolCallParser: 'gemma',
})
```

## The parser does not get a vote on authorization

This one surprises people: a tool-call parser **surfaces every call the model emits** — including a call to a
tool that does not exist. It does not silently drop the unknown one.
That looks wrong until you see the loop it serves: the dispatch layer answers a bad call with
`Tool not found: <name>. Available tools: …`, and the model corrects itself on the next turn. A parser that
ate the unknown call would hide both the request and the feedback that fixes it. Authorization — *is this tool
allowed* — is the consumer's decision, made in the dispatch layer where the registry actually lives, not a
silent veto buried in string parsing. The parser's only job is structural: *did the model ask for a tool, and
which one.*
