Native apps in model chat formats
Contract authority: public contracts, language and harness.
The app declares content, token budgets and on* handlers. A wrapper calls
asChatTools(children) to lay out that tree, project its visible model actions
into function schemas, and return { tree, tools }. Emit tree as a child. It
holds token IDs and spans, not a decoded string. The wrapper inserts those IDs
after its protocol header and rebases the spans into the final prompt.
views/demo/notebook_app.tsx is one app with a scrollable list and a notes
input. It contains no model control tokens or tool table. These entry files
import the same app:
| Format | Entry file | Wrapper |
|---|---|---|
| Qwen3-Instruct-2507 | views/demo/notebook_chat.tsx | Qwen3App |
| Llama 3.1 | views/demo/notebook_llama31.tsx | Llama31App |
Build the native app before entering the wrapper if its state should survive
switching formats. That keeps the hook path the same. Each view must be loaded
with that model's encoder. Explicit entries may import a single template; runtime apps can
use ModelContext from views/lib/model.tsx for binding-selected templates. Stock templates
can be imported together; unavailable token-dependent constants error only if used.
import { Notebook } from "./notebook_app.tsx";
import { Qwen3App } from "../adapters/qwen3.tsx";
export default function NotebookChat({ numbers, question }) {
const app = <Notebook key="notebook" numbers={numbers} budget={48}/>;
return <Qwen3App question={question}>{app}</Qwen3App>;
}An onChange on label="notes" becomes change__notes. Use unique, stable
labels for stable names. Lossy or truncated names carry a deterministic suffix
derived from their full UTF-8 label, so distinct labels such as a b and a_b
keep their identities after rows reorder. Exact duplicate or unnamed targets
still use handler positions and can change names when render order changes.
Each projected span still refers to its original
handler. View::act dispatches through that reference, including captured
variables and useState setters. The model cannot call timeout, interval
or callback through the projected tools; those spans retain their harness
names.
asChatTools(children, {callable: false}) keeps the same schemas and token content but removes
handler references from the projected tree. TurnStrategy callable={false} exposes this for
summary/preview views. It controls model calls, not the surrounding document's event listeners
or effects.
An explicit args_schema prop is copied into the tool's parameters. Without
one, a destructured handler declares its field names with unconstrained JSON
schemas ({}). Fields with defaults are optional. A single event-object
parameter declares an unconstrained object. The adapter does not infer primitive
types from parameter names. Schemas describe the interface; View::act does
not implement a JSON Schema validator.
All budgets that can hide actions must be inside asChatTools. Layout first
removes hidden or partly clipped actions, then projection declares only the
remaining ones. A later outer crop that removes a projected action is an error,
with instructions to move that budget inward. The system/tool section and
protocol control tokens should stay outside app budgets.
Native trees are hard tokenization boundaries. This preserves their IDs and action ownership. It can intentionally differ from tokenizing one concatenated plain string, especially at whitespace boundaries. The Llama adapter preserves native app whitespace instead of applying the official template's string trim to the tree. Six independent tests compare simple, untrimmed app bodies to official cached Qwen and Llama templates, including tools, no tools and Unicode. They do not prove arbitrary styled apps match full-string tokenization.
affordance render renders custom views from a JSON object of app props. --state -
reads stdin; --batch accepts an array of states and --cache reuses text runs.
--json writes IDs and rebased spans without decoding the prompt. Invalid
input and rendering failures return a nonzero status; machine-mode rendering
errors have an error field.
./target/release/affordance render --model Qwen__Qwen3-4B-Instruct-2507 \
--view views/demo/notebook_chat.tsx --entry NotebookChat \
--state views/demo/notebook_state.json --jsonThe renderer and adapter contract tests cover exact native token preservation, whitespace boundaries, visible action projection and rebased spans. Flat-string tokenization can differ at native subtree boundaries, as described above.
# Regenerate protocol references with Python; product rendering does not use Python.
TOKENIZERS_PARALLELISM=false .venv/bin/python tools/adapter_references.py
cargo test --release --test adapters --test adapter_contract
cargo run --release --example adapter_benchProjection keeps an encoder-bound run cache on the loaded View. render enables it when
the caller's outer RunCache is enabled; act reuses native runs. This caches encoded text,
not schemas, spans, closures or hook state. View::projection_cache_stats() reports its
counters separately from the caller's outer cache. adapter_bench checks token IDs while
measuring native and wrapped renders; its timings exclude model inference.
Earlier frozen-model studies, QuickJS experiments and benchmark archives belong to the separate research project. They are not additional shipped execution paths. Current host execution and app-owned history are described in RUNTIME.md and HARNESS.md.
Source: docs/ADAPTERS.md, about 4 minutes to read.