Docs / Writing apps

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:

FormatEntry fileWrapper
Qwen3-Instruct-2507views/demo/notebook_chat.tsxQwen3App
Llama 3.1views/demo/notebook_llama31.tsxLlama31App

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.

tsx
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.

sh
./target/release/affordance render --model Qwen__Qwen3-4B-Instruct-2507 \
  --view views/demo/notebook_chat.tsx --entry NotebookChat \
  --state views/demo/notebook_state.json --json

The 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.

sh
# 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_bench

Projection 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.