# Affordance: all documentation This file is every documentation page of https://affordance.rs/ as Markdown, in reading order. A shorter summary is at https://affordance.rs/llms.txt. --- Source: docs/GETTING_STARTED.md Page: https://affordance.rs/docs/getting-started.html # Getting started You need Rust 1.89 or later. It runs on Linux and macOS; see [platform evidence](DEVELOPING.md#current-platform-evidence). Python, Node and model weights are not needed. ## Install From a source checkout, until the first crates.io release: ```sh cargo install --path . ``` This installs one command, `affordance`. Everything below uses it; you do not write Rust to build an app. ## Render an app ```sh echo '{"name":"world"}' | affordance render views/demo/hello.tsx --state - ``` It prints `Hello, world! [greet]` and its spans. The greeting span marks text; the `greet` span marks a visible action and the handler it runs. `--json` prints `ids` and `spans` for another program. With no `--tokenizer`, `render` uses its bundled default (GPT-2), enough for an app that writes no model-specific chat format. The app is [views/demo/hello.tsx](../views/demo/hello.tsx): ```tsx export default function Hello({name = "world"}) { return {`Hello, ${name}!`} ({name})}>{" [greet]"} ; } ``` This is Affordance's TypeScript/JSX subset, interpreted in Rust (not React or Node). A render evaluates the app against its state, then layout decides its exact token IDs and the actions that survive. An action's handler changes the state; the next render shows it. See [the language](../spec/LANGUAGE.md). ## Your first agent ```sh affordance init my-agent cd my-agent ``` `init` writes these files: - `app.tsx`: the agent. It shows the model its task and the log of what it has seen and done, offers a `shell` tool and an `answer` tool, and loops until the model answers. - `affordance.yaml`: where it runs. A model server (any OpenAI-compatible server on `127.0.0.1:8080`), a machine (`home`: this machine's shell) and the agent with its task. - `.gitignore`: the run's journals (`.affordance/`) and local settings (`affordance.local.yaml`). - `affordance.d.ts` and `tsconfig.json`: types for your editor (what `harness`, `model()`, the hooks and the elements are), so it completes and checks the app. They describe the platform; nothing runs them. Start a model server. No key is needed for a local one: ```sh llama-server -m your-model.gguf --port 8080 --jinja # llama.cpp # or: ollama serve (then use --set model.url=http://127.0.0.1:11434/v1 --set model.model=) # or: vllm serve --port 8080 ``` Then run the agent: ```sh affordance run ``` `affordance run` finds `affordance.yaml` in the current directory (or a parent), runs the agent and prints a report when it stops: `"stopped": {"answer": "..."}`. Each agent's journal and trace are in `.affordance//`. The journal records every input the agent received, so `affordance run --resume` continues the run exactly; `affordance run --fresh` starts over. ### Change a setting - For one run: `affordance run --set model.url=http://other:8080/v1 --set model.context=32768`. A key is a dotted path; a value is YAML. - For this machine only: put it in `affordance.local.yaml` beside `affordance.yaml` (it is not committed). It layers over the main file. - From the environment: write `${VAR}` or `${VAR:-default}` in a value. - To see what the run will use: `affordance config` prints the resolved deployment, secrets hidden. A hosted API takes its key from an environment variable (read at each call, never written to the journal): ```yaml model: kind: chat url: https://api.example.com/v1 model: some-model context: 128000 key_env: EXAMPLE_API_KEY ``` See [model bindings](MODEL_BINDINGS.md) for SGLang and vLLM token-id serving, Anthropic and OpenAI Responses, and [running agents](RUNTIME.md) for the full deployment format. ## The library An app imports the bundled TSX library with `@affordance/` paths: ```tsx import { useLog } from "@affordance/lib/log.tsx"; import { ModelContext } from "@affordance/lib/model.tsx"; ``` `@affordance/` names the library as an import map names a package: the copy bundled with the `affordance` command, or a directory the deployment names with `library: path/to/views`. Your own files import each other by relative path. ## Next - **Write apps:** [running agents](RUNTIME.md), [the language and layout](../spec/LANGUAGE.md), and the apps in [`views/demo/`](../views/demo). - **Add HTTP or MCP:** [Fetch and MCP](HTTP_AND_MCP.md). - **Embed the engine in a Rust program** (not needed to build apps): the [harness guide](HARNESS_GUIDE.md) and [examples/embedding/hello.rs](../examples/embedding/hello.rs) (`cargo run --release --example hello -- TOKENIZER.json`). - **Contribute:** [contributing](../CONTRIBUTING.md) and [development](DEVELOPING.md). --- Source: examples/first_agent/README.md Page: https://affordance.rs/docs/first-agent.html # A first agent An agent that reads its task, may run shell commands on this machine, and stops when it answers. `app.tsx` is the whole app; `affordance.yaml` binds it to a model server and a machine. `affordance init` writes these same two files into a new directory. ## Run it 1. Start an OpenAI-compatible model server on port 8080. No key is needed: ```sh llama-server -m your-model.gguf --port 8080 --jinja # llama.cpp # or: vllm serve --port 8080 # or: ollama serve (then add --set model.url=http://127.0.0.1:11434/v1 --set model.model=) ``` Set `context` in `affordance.yaml` to your model's context length (the server's `-c`). 2. Run the agent from this directory: ```sh affordance run ``` The report prints when the agent stops: `"stopped": {"answer": "..."}`. Its journal and trace are in `.affordance/first/`; `affordance run --resume` continues the run, `--fresh` starts over. For a hosted API, set `url`, `model` and `context`, and `key_env: NAME` for the environment variable that holds its key. `affordance config` shows what a run will use. `tests/first_agent.rs` runs this example, as written, against a stand-in server. --- Source: examples/README.md Page: https://affordance.rs/docs/examples.html # Examples An Affordance app is a folder with an `app.tsx` and an `affordance.yaml`, run with `affordance run` in that folder. You do not write Rust to build one. - **[first_agent/](first_agent/README.md)**: start here. A complete agent in one TSX file: it reads its task, may run shell commands on your machine, and stops when it answers. `affordance run` in its folder runs it against a local model server (llama.cpp, Ollama, vLLM) or a hosted API. `affordance init` writes a copy to start from. - More apps: a long-session agent with context folding ([`views/agents/session.tsx`](../views/agents/session.tsx)), and in [`views/demo/`](../views/demo) a chat, a research agent comparing context policies, a memory that outlives its sessions, and a recursive language model (RLM). Only if you are embedding the engine in your own Rust program: - **[embedding/hello.rs](embedding/hello.rs)**: load an app and a tokenizer, render it, call an action and render the result, from Rust (`cargo run --release --example hello -- TOKENIZER.json`). --- Source: docs/DESIGN.md Page: https://affordance.rs/docs/design.html # Why Affordance is shaped like a browser This document explains where the line between the engine and an app is, and why. It is for anyone extending Affordance, person or model, who wants to predict the answer to "where does this feature go?" before asking. The contracts are in [spec/](../spec/CONTRACTS.md); this is the reasoning behind them, shown through decisions that were actually made. ## The claim A web browser renders the internet's state into pixels for a person, and lets the page react to what the person does. Affordance renders an app's state into tokens for a model, and lets the app react to what the model does. The analogy is not decoration. The web platform has already answered, under pressure from millions of applications, most of the questions an agent harness faces: how a page waits, fetches, cancels, talks to other pages, survives a reload, and is watched by a developer. When a need appears here, the first question is what the web's answer was. | The web | Affordance | |---|---| | A page: markup, state, event handlers | An app: TSX, `useState`, handlers on `` and `useEvent` | | Layout: CSS fits content to a viewport | Layout: token style sheets fit content to a context window | | A link or button the user can see and use | An affordance: an action attached to tokens that survived layout | | The event loop | One instance: ordered inputs, tasks, microtasks | | `fetch` | `model()` (a fetch of a view), `harness.machine.call`, `fetch` | | `AbortController` | `AbortController` | | `postMessage` between windows | `harness.send` between instances; `Runtime::inbox` from outside | | An iframe | ``: a child instance with its own state and journal | | A Service Worker answering `fetch` events | `affordance serve`: the event `request`, answered on the machine `client` | | DevTools: console, network panel, the protocol | `console`, the observer, `affordance host` | | Reload, session restore | The journal: replay rebuilds the instance exactly | ## Three owners Every feature belongs to one of three owners. Most disagreements about a design are really disagreements about which. 1. **The app** (TSX, including the libraries in `views/`). What the model sees, which tools exist, when the model is called, how history is kept and folded, what needs the user's permission. A website's product decisions. 2. **The engine** (the language, layout, the instance's event loop, effects, the journal). What every app needs and no app could build for itself: exact tokens, deterministic replay, cancellation that actually stops work. The browser. 3. **The deployment and its host program** (`affordance.yaml`, or a program embedding the runtime). Which model server, which machines, which credentials, what an agent is allowed to reach. The user's computer and network. A browser does not know what a shopping cart is. The engine does not know what a coding agent, a conversation, a permission prompt or a file is. ## The test for a new need 1. **Name the web's primitive for it.** If a web application would solve this with standard APIs, say which. 2. **Compose first.** Most needs are an arrangement of primitives that already exist. Write that arrangement. If it works, the feature is library TSX or deployment configuration, and the engine does not change. 3. **If something is truly missing, add the smallest general primitive**: nothing in it names a product, a vendor, a file or a process; it is useful beyond the case that prompted it; a replay reproduces it; it behaves under cancellation, pause and restore. 4. **Check the ordinary cases**: a chat bot, an autonomous tool-using agent, a fleet of agents, a resumed journal. If one needs a workaround, the primitive is wrong. ## Decisions, and why they went the way they did **Stopping a generation.** The need: the user presses Escape; stop now, and keep what was written. The tempting design is a harness call, `interrupt()`. The web's answer is `fetch(url, {signal})` with an `AbortController`, so `model()`, machine calls and `fetch` take a signal. Two details follow from taking the parallel seriously. An aborted `model()` *resolves* with the partial output instead of throwing, because the app is the reader of a stream and a stream's reader keeps the chunks it read. And whether a signal is aborted is kept by the instance, not in the controller object, because what an app keeps in state is frozen and must be reproducible from the journal. **Time and randomness.** A handler that read the wall clock would behave differently on replay. So `Date.now()` is the instance's clock, which advances only with journaled inputs, and `Math.random()` is refused with a message naming `harness.random()`. The rule behind both: everything a handler observes arrives as a journaled input. **`console.log`.** An app needs to say things to its developer. If those lines were state, they would change renders; if they were journaled, debugging would change the journal. A browser's console is neither: it is the DevTools view of a page. Here, console lines go to the instance's trace, which observers see, and nowhere else. **The loop is not the engine's.** The runtime never calls the model on its own. An autonomous agent (`useLoop`), a conversation (`useConversation`) and a chat driven by a frontend (`useFrontend`) are each a few dozen lines of library TSX over the same primitives, and an app can copy and change them. A browser has no opinion about when a page fetches. **Fitting the context window.** Dropping old history and eliding old tool results is layout, as `overflow` is in CSS: the engine does it, exactly, in tokens. Deciding to summarize, and what a summary says, is the app's: it listens for the layout event and makes an ordinary model call. **Serving HTTP.** The need: put an app behind an HTTP endpoint. The web's answer is a Service Worker's `fetch` event and `respondWith`. So a request is an event to the app, and the response is a call on a machine named `client`. Routing, JSON, and an OpenAI-compatible API are library TSX. The one thing that was missing was a way for a program outside to post an event into a running runtime; that became `Runtime::inbox`, which knows nothing about HTTP. **A frontend.** A terminal UI needs to drive an agent and watch it. The web's answer is the DevTools protocol: a second channel that sees the page without being the page. `affordance host` is that channel. A permission prompt was not added to it. Instead the frontend is a machine, `ui`, and an app that wants permission calls it and waits. Which actions need permission is therefore the app's decision, where a product decision belongs. **What the frontend is told.** A frontend wants to know when a tool starts, and to redraw a conversation after a restart. Both were first requested as protocol events. Both turned out to be compositions: the chat library tells the frontend the model's calls before it runs them, and answers the event `history` with its log. The protocol did not change. **File tools.** A coding agent wants `read`, `write` and `edit`. They are `views/tools/files.tsx`: library functions over a machine's shell. Nothing about them needed the engine, so the engine has no notion of a file. **A wait that watches a process.** A client wanted `wait` to end when a process on a guest machine exited. It was built into the engine, then removed: watching a guest is a property of that client's machines, not of waiting. The client composes it from a machine call and `harness.wait`. ## How to tell you are on the wrong side of the line - The engine change mentions a product noun: file, repository, permission, conversation, tool name, vendor. - The feature could be written in an app today, only less conveniently. Then it is a library. - A replay would not reproduce it, or would need the outside world to cooperate. - It makes one of the ordinary cases special. And the opposite error, rarer but real: an app is asked to do what only the engine can do honestly, such as counting tokens by estimate, cancelling work by ignoring its result, or remembering by re-reading the outside world. --- Source: spec/LANGUAGE.md Page: https://affordance.rs/docs/language.html # The view language Views are TypeScript/JSX files (`*.tsx`) implementing the JavaScript subset specified here. Intentional differences are in §7.3; unsupported features are in §7.4. Everything here is implemented in `src/` (`tsx.rs`, `parser.rs`, `interp.rs`, `jsvalue.rs`) and exercised by `views/`, including the 57 conformance views that reproduce the official chat templates token for token. ## 1. What a view is A view is a program that turns **state** (a JSON object) into a **list of token ids** for one model's tokenizer, plus **spans** (labelled token ranges, affordances, token-budget boxes). It never builds the prompt as one string. Special tokens are emitted as ids; text is emitted in *runs* that are encoded with the model's tokenizer (section 6). ``` state (JSON) ──► view program ──► [ids] + [spans] ▲ bound to a tokenizer at load time ``` For chat templates the state is the object the official template gets: `messages`, `tools`, the kwargs (`add_generation_prompt`, `enable_thinking`, ...) and the tokenizer's special-token strings (`bos_token`, `eos_token`, ...). ## 2. Files The bound `tok` namespace is immutable from creation. Global `fetch`, `Headers` and buffered `Response` are supplied by the embedded Web API standard library. Fetch runs in effects/handlers through an explicitly bound HTTP executor; it grants no network access by itself. See `docs/HTTP_AND_MCP.md` for the supported Fetch subset, named capabilities and standard-library behavior. ```tsx "use strict_tokens"; // directives (6.3, 6.4) import { pythonTypeName } from "../common/checks.tsx"; // explicit file binding import "./chat.tss"; // a token style sheet (4.1) const NAME = expr; // evaluated once per render, before the root function name(a, b = 1, ...rest) { statements } const Turn = ({ role, children }) => p`${tok.im_start}${role}\n${children}${tok.im_end}\n`; export default function Main({ messages, tools }) { return <>...; } ``` The root is a function named by the caller (e.g. `Qwen3`); it receives the whole state as its props object. Declarations are private to their file; imports bind explicit exports (§2.1). TypeScript syntax is parsed and erased: annotations of any shape (object, union, tuple, function, conditional and template-literal types), optional parameters (`x?: T`), `type` and `interface` declarations (exported or not), type parameters on functions and arrows (`(x: T) => ..` in TSX), type arguments at calls (`useState([])`), `as` / `satisfies` casts and the non-null `x!`. Comments: `//`, `/* */`, and `{/* ... */}` in JSX. The Rust `View::render` API accepts arbitrary app state. `View::render_chat_opts` adds the Hugging Face chat wrapper's tool-schema validation and iterable-to-list conversion before rendering a chat view. Validation is outside the core language; an app may use a state field named `tools` for its own data. A JSON integer larger than 2^53 is a number (as in JavaScript) that keeps its exact decimal digits when printed; arbitrary-precision arithmetic is not implemented. ### 2.1 File modules ```tsx import MainView from "./main_view.tsx"; import { Tool, lookup as find } from "./tools.tsx"; import DefaultView, { helper } from "./views.tsx"; import "./setup.tsx"; // load/evaluate; introduces no names import type { Input } from "./types.tsx"; // erased; no runtime dependency export function Widget(props) { return ; } export { find as search }; export default function Main(props) { return ; } ``` Functions, constants and classes are private unless exported. Supported exports are `export function`, `export const`/`let`, `export class`, named default declarations and `export {local as public}` (including `as default`). A default export does not also create a named export. An entry selects a function declared in its root file, falling back to that file's default export when the requested declaration is absent. Relative paths resolve from the importing file; absolute paths are also accepted. A path that starts with `@affordance/` names a file of the library (`@affordance/lib/log.tsx`), as an import map names a package: the deployment's `library` directory, or the copy bundled with Affordance. Source files are `.tsx`, styles are `.tss`. Canonical file identity deduplicates a dependency in a program, including diamond imports. Constants initialize dependency first, in source order; a read of an uninitialized module constant throws a ReferenceError. Module constants are frozen as described in §7; modules do not provide mutable persistent state. Startup initialization still happens once per render. Declared functions keep compiled-declaration identity across renders for equality, Map/Set keys and effect dependencies, while retained functions execute against their defining initialization's lexical snapshot. Each runtime source load creates fresh declarations (§2.2). Named/default imports introduce only their listed local bindings. Export aliases preserve the identity of the referenced value. Functions, parameter defaults, closures, class methods and tool type-schema lookup resolve names in their defining file, including during effect replay. Imported JSX components and mounts resolve the same function; a mounted child's entry uses its declared function name. Render inputs, builtins and platform globals remain available unless shadowed by lexical bindings. Style sheets retain the global cascade described below. Duplicate declarations/import bindings/exports, missing exports and exports of undefined bindings are load errors. Cyclic imports, namespace imports (`import * as ...`), re-exports with `from`, dynamic imports and package resolution are unsupported and fail explicitly. Type-only imports are erased; cross-file type aliases are not available to `schemaOf`. The built-in module `affordance:web` exports `fetch`, `Headers`, `Response`, `createFetch`, `AbortController` and `AbortSignal`; its implementation's private names are inaccessible to apps. All but `createFetch` are also shadowable platform globals (abort signals: `spec/HARNESS.md` §3). `createFetch` selects an explicitly bound HTTP role; see [HTTP_AND_MCP.md](../docs/HTTP_AND_MCP.md). ### 2.2 Runtime source modules `loadModule(source, {imports?})` from `affordance:compiler` returns a frozen export namespace using the same parser and executor as file modules. `imports` maps exact import specifiers to objects containing named/default exports; installed `affordance:` modules resolve automatically. Loading performs no filesystem/network lookup. Each load creates a distinct lexical instance; functions, defaults, closures and type schemas retain it across later calls and awaits. ```tsx import {loadModule} from 'affordance:compiler'; // In a handler, then retain exports in state: const next = loadModule(source, {imports: {'./helpers.tsx': {format}}}); // In the parent component: // ``` Acquire source in recoverable app inputs and keep exports in state rather than loading on every render. A lazy state initializer may load initial source. Source parse/initialization errors are catchable before selecting a revision. The app owns revision history and controls; a new `key` resets child-local state, and undo selects old code with fresh local state after removal. Source is charged to execution fuel. Global fuel exhaustion aborts the render, including its parent; source modules share the instance's capabilities and computation allowance. Initialization is synchronous and pure for both file and source modules: no hooks, async calls, await, Promise reactions, host effects, interactive JSX, scroll viewports or mounts. Put those inside component/function bodies. Passive text/token constants and class definitions are valid. Initializers cannot see the loader caller's locals; documented app render inputs and platform globals remain available. Imported values are deep-frozen, including captured closure cells; module `let` is read-only just like file-module constants. Runtime modules use the bound tokenizer, parent styles and document policy. Style-file imports are unsupported; use inline styles or the parent's existing stylesheet. `strict_tokens` applies normally; `safe_text` must already be selected by the parent. Unknown directives/options and missing imports/exports fail explicitly. `js` remains a no-op. `` needs a file-backed entry; a file-backed wrapper can load source from its props to run a separate instance. See the [live-component example](../views/demo/live_components.tsx). ## 3. JSX and prompt text Components are functions that return a value; elements are values. Children follow React's rules: strings as text, numbers as JavaScript prints them, arrays spliced (each element is one item for a dropping box, 4.1), `true`/`false`/`null`/`undefined` render nothing, a plain object is an error. Component tags resolve function values: `` may use a local, prop or closure, and `` reads an object member. Intrinsic spellings (`Mount`, `tok.*` and the lowercase elements below) remain reserved. A missing or non-callable component is an error; a string value does not name an intrinsic element. Elements have structural paths. An explicit string/number `key` identifies a sibling; duplicate sibling keys are errors. Otherwise identity uses the JSX tag spelling and its evaluated sibling position among that tag. Arrays splice in order; null/false/undefined create no element. Hooks use that path and hook order. `id` and `label` select/describe presentation and do not reset hooks. Fresh function values at the same path preserve state; this is different from React's function-object identity. Removing a component discards its local state and cleans up its effects; named `useState(init, "name")` fields remain instance-wide. | syntax | meaning | |---|---| | `` p`text ${x} more` `` | a **prompt fragment**: literal text (split on special tokens at load time) and values in order | | `` `text ${x}` `` | a plain string (JavaScript template literal) | | `{expr}` in JSX | a child (rules above) | | `children` | call a function component with a props object (`children` is a fragment) | | ``, `${tok.im_start}` | one special token (6.4) | | ``, `` | a special token chosen at render time; a raw token id | | `..` | labelled span (token range is reported); extra props travel as meta | | `..` | an **affordance**: an action attached to the tokens of its children (section 5) | | `..` | token-budget box (section 4) | | `` | `n` placeholder tokens | | `..`, `<>..` | grouping only | Any other lowercase tag is an error: prompt text such as `` or `` is written inside `` p`...` `` or a string, never as JSX. JSX text follows React's whitespace rule (a whitespace run containing a newline is dropped), so prompt layout is written in `` p`...` ``. ## 4. Token-aware styles Style properties are measured in tokens of the bound tokenizer. | property | element | meaning | |---|---|---| | `max_tokens={n}` | `box` | content may use at most `n` tokens; overflow is clipped | | `tok_size={n}` | `box` | clip to `n` tokens; an explicit `pad` fills shorter content to `n` | | `tok_size={n}` | `img` | exactly `n` placeholder tokens | | `overflow="clip-end"` (default), `"clip-start"`, `"scroll"`, `"drop-start"`, `"drop-end"` | `box` | clipping, token scrolling, or whole-item dropping (4.1) | | `more="…"` | `box` | marker text placed at the cut, counted inside the budget | | `pad="<\|pad\|>"` | `box` | padding token for `tok_size` | A box is a **hard tokenization boundary**: its content is encoded on its own, so clipping is exact and never splits a merged token. The box reports `content_tokens`, `hidden_tokens` and `overflow` in its span, so a view (or the agent) knows exactly what was hidden. ### 4.1 Token style sheets (`.tss`) A view can import a style sheet (`import "./chat.tss";`). Rules are CSS-like with token units: ```css ToolOutput { max-tokens: 32tok; more: "\n[... truncated]"; } ToolOutput[full] { max-tokens: 4096tok; } /* more specific: wins */ Reasoning[old] { display: none; } /* renders nothing, costs nothing */ #scratch { tok-size: 16tok; } ``` - Selectors apply to both component wrappers and native elements (`box`, `span`, etc.): `Component` (the JSX tag spelling, including member tags), `*`, `#label` (the `id` or `label` prop), `[prop]` (truthy), `[prop="v"]`, and compounds of these (`Turn[role="tool"]`), and descendant combinators: `A B` matches a B inside an element matching A (`Context[policy="trunc"] history`), so an app styles what a template it renders writes, by its own props. - Specificity: inline `style={{...}}` > `#id` > `[prop]` > element/component name; later rules win ties. Native presentation attributes such as `max_tokens` are defaults below sheet rules. No token property inherits. Inline property names accept camelCase or kebab-case; direct native presentation attributes retain snake_case. - Properties: `max-tokens`, `tok-size`, `overflow: clip-end | clip-start | clip-middle | scroll | drop-start | drop-end | drop-children`, `drop-step`, `more`, `pad`, `display: none | block | contents`. A token size or budget creates a token box (4) around the element/component result. A native `box` always provides one box, never an extra box for the same style. `display: none` emits no tokens, presentation spans or model-callable actions and adds no encoding boundary, while its components, hooks, effects, event listeners and child instances stay mounted. Conditional removal, not CSS visibility, unmounts them. Invalid property names/values fail explicitly; `null`/`undefined` inline values are omitted. Nested boxes apply the child's limit first, then the parent's overflow policy; they do not divide the budget equally among siblings. - `overflow: drop-start | drop-end` drops **whole items** instead of cutting tokens: the box drops items from one side until the rest plus the `more` marker fits, keeps at least one item (clipping it only if it alone is too big), and reports `dropped_items`. This is the history budget an agent needs: older messages disappear whole, structure stays valid. Items are the elements of an array: the list a styled component returns (`messages.map(m => )` with `overflow: drop-start` in a `.tss` rule or inline `style`), or an array child of a `` (`{xs.map(x => ...)}`). - `max-tokens` is a token count (`1500tok`, or a number) or a share of the prompt's **window** (`80win`: 80% of it). The window is the model's context (the binding's `context`) less the reply's room (`max_new`; an ad-hoc `model(, {max_new})` uses its own). With no window (the binding states no `context`) a share caps nothing, as a page with no viewport is not clipped; set `context` on the binding. A prompt that does not fit the context with its reply is never sent: the call fails with a `ContextError` naming the largest boxes. - `drop-step: 25%` (of the box's cap) or `drop-step: 400tok` makes a drop **stable**: the amount dropped is rounded up to a multiple of the step, and the cut is the first item boundary at or past it. Content that grows on the far side (a history) then moves the cut once per step of new tokens, not on every new item, so the kept prefix (and a server's prefix cache) survives many turns. A step leaves at least `cap - step` tokens of items. - `overflow: clip-middle` keeps the head and the tail (the head gets the odd token) with the `more` marker between them. In `more`, `{hidden}` is the number of hidden tokens and `{id}` the box's `id`/`label`: `ToolResult { max-tokens: 1500tok; overflow: clip-middle; more: "\n[... {hidden} tokens hidden, read_event {id}]\n" }`. - **Graded results:** `result:nth-last(n+K / S)` matches a tool result at least K from the end among the results of its history (in document order; the current turn's are not counted), the boundary moving S results at a time, so a step rewrites S results once every S appended. It counts as an attribute in specificity. With `overflow: elide` the matched result shows its `more` marker alone: `result:nth-last(n+20 / 10) { max-tokens: 64tok; overflow: elide; more: "[output elided: {hidden} tokens; read_event(\"{id}\")]"; }`. Results are boxed only when a rule applies to them apart from position; a history drop measures graded results at their static length, so grading never moves its cut. - After a page prompt is laid out for a `model()` call, a `layout` event reports what its boxes did, as `ResizeObserver` reports sizes: `useEvent("layout", f => ...)` gets `{prompt_tokens, window, context, max_new, boxes: [{label, tokens, content_tokens, hidden_tokens, dropped_items}]}`. It is delivered in the same input, only to an app that listens. An app schedules a fold or a summary from it; nothing measures during render. - Item boundaries are marks, not tokenization boundaries: a drop box that fits encodes exactly as its content would without it. A cut that would fall inside a token two items share is an error. - Template apps mark what they write: `` around each protocol turn (a unit a drop removes whole: a message, a tool group, a GLM assistant with its observation block, a DeepSeek exchange) and `` around the run of history turns, between the system turn and the current screen (``). Both are transparent label spans. With a rule they are boxes whose items are their children: `history { max-tokens: 60win; overflow: drop-start; drop-step: 25%; }` drops whole turns from the start and leaves the system turn and the screen as the template wrote them. For a hosted API (the `messages` template) layout runs before serialization: the same rule drops whole messages (a call with all its results is one unit), measured with the bound tokenizer (approximate; the provider's count differs), and the window check counts the JSON that is sent. When what is left no longer starts with a user message (an agent's one user message was the oldest), a user message stands where the drop was: the rule's `more` text, or a default sentence saying earlier messages were dropped. - `overflow: drop-children` drops oldest whole items and may retain zero items. Unlike `drop-start`, it never clips a final oversized item. This keeps complete chat exchanges intact even with a zero budget. The marker is clipped if needed to fit the budget. Hidden actions disappear; retained spans use final token positions. ### 4.2 Scroll viewports `overflow: scroll` with a token budget/size creates an executable token viewport on either a native element or a component wrapper. Its offset starts at zero and is keyed by element identity, independently of application hook order. Layout slices already measured token IDs; it never decodes and re-encodes the window. The optional `more` marker is counted inside the budget and appears at the end when content is omitted. Hidden content has zero extent. When the content exceeds a nonzero viewport capacity, one `scroll` action accepts `{by: integer}` in token positions. Its metadata includes `scroll_offset`, `scroll_max` and `content_tokens`. Dispatch clamps against the captured view's bounds and applies movement to the live offset; the next layout re-clamps if the content changed. A resize retains the stored requested position without a render-time write; the next scroll action commits its clamped position. Fitting content, a zero budget, or a marker consuming the whole budget publishes no automatic scroll action. An `onScroll` observer receives `{by, offset}` after the platform write, with `by` equal to the applied delta. The default action result is the new offset. Synchronous observer writes and the offset update share one action prefix; async observer work follows normal task semantics. Ordinary `onX` actions, including app-defined scrolling over a data source, remain available without a platform viewport. Explicit labels/IDs identify model action targets; otherwise the structural path does. Ancestor clipping removes partly clipped scroll actions. Historical snapshots retain viewport output while dropping executable handlers as for other actions. ## 5. Affordances `children` (or `onX` on a `` or ``) marks the tokens of `children` as the target of an action. Other attributes are copied into the span's `meta`. The renderer returns, for each affordance, its name, arguments and token range `[start, end)`. Example (`views/demo/scroll_list.tsx`): ```tsx const NumberList = ({ numbers, offset, budget, onScroll }) => {numbers.slice(offset).map(n => p`${n}\n`)} ; ``` The tool list is therefore part of the view: an action exists exactly where it is rendered. An `aff` element is a **hard tokenization boundary** (its content is encoded on its own), so an action always owns whole tokens. Found in testing: inside one run, `[scroll up][scroll down]` encodes `][` as one token, so a soft label cannot start cleanly. Plain `` labels stay soft (they do not change tokenization); a `span` whose boundary falls inside a merged token is reported with `straddle: true`. ### 5.1 Native apps as chat tools `asChatTools(children)` lays out a native app and returns `{ tree, tools }`. `tree` retains native token IDs and visible spans. Emit it as a child inside a model wrapper; do not convert it to text. Model-callable `on*` actions become function schemas with names such as `scroll__numbers`, retaining their original handler references and hook state. Harness callbacks are excluded. `asChatTools(children, {callable: false})` preserves the same tool schemas and token content, but removes handler references from the returned tree and adds `callable:false` metadata. Turn plans retain this restriction; hosted adapters disable provider calls. Use it for summaries, previews or measurement without exposing model-callable actions. Derive aliases before removing handlers, so names match an active projection of the same screen. This does not disable hooks, effects or event listeners in the surrounding document. Set `tool_name="shell"` for an exact tool name (otherwise `verb__label`, e.g. `run__shell`; see `docs/HARNESS_GUIDE.md`). Use unique stable labels for stable tool names, and `args_schema` for explicit JSON types or constraints. Otherwise destructured parameter names have unconstrained JSON types and defaults make fields optional. A single object parameter has an unconstrained object schema. Runtime argument validation is the application's job. Apply budgets before projection; a later crop that hides a projected action raises an error. Native trees form hard encoding boundaries. See `docs/ADAPTERS.md` for formats, examples and limits. ### 5.2 Replies A model reply is optional thinking, then tool calls in the model's own format, then the end. The template app's zero-token `protocol` span names the format (`syntax`), so the harness reads each call as `(name, args)`, checks it against the rendered affordances and their `args_schema`, and runs the named `on*` handler (`src/reply.rs`; the harness side is `spec/HARNESS.md` §3). A call to an action that is not in the render, or with arguments that fail the schema, becomes an error result the model sees. A reply with no call is allowed; whether the model is called again is the app's (`model()`). ## 6. Text and tokens ### 6.1 Runs Consecutive text pieces form a **run**; a run ends at a special token, a box boundary, or the end of the view. Each run is encoded once with the tokenizer (`add_special_tokens=false`) and the ids are appended. Runs are cached by content, so re-rendering a conversation re-encodes only new text. Tokenizers with nonzero BPE dropout are rejected at load: stochastic encoding cannot satisfy the exact-token contract. Dropout zero or null is accepted. Why runs and not per-piece encoding: BPE merges and pre-tokenizer splits cross piece boundaries (`"\n\n"` + `"Hello"`, `"user\n"` + `"\n..."`). Added tokens are hard boundaries in every Hugging Face tokenizer, so per-run encoding gives exactly the ids of tokenizing the whole string, provided two tokenizer rules are reproduced (both automatic): 1. **Position:** SentencePiece-style tokenizers with `prepend_scheme: first` add the `▁` prefix only to the text at the very start of the prompt. The renderer detects this at load time and encodes non-first runs accordingly. 2. **Added-token flags:** tokens with `lstrip`/`rstrip` swallow the whitespace next to them. Dynamic text (`{expr}`) that contains an added-token string (e.g. `"<|im_start|>" + role`) is split by the emitter at that token, exactly where the tokenizer's added vocabulary would split it (leftmost-longest match; `lstrip`/`rstrip` honoured). The ids are unchanged, but each message then becomes its own cached run. In `safe_text` mode this split does not happen (6.3). Measured (boundary study, `affordance boundaries`): when each text piece is encoded on its own instead of per run, 28% of dev renders and 33% of held-out-3 renders change. Piece boundaries that merge: space|letter 100%, space|newline 100%, newline|newline 99.8%, punctuation|newline 85%, space|punctuation 63%, punctuation|punctuation 26%; newline|letter and newline|digit never. ### 6.2 What is *not* a boundary Two text pieces next to each other are one run even if they come from different components or expressions. If the view wants a boundary without a token, it uses a `box` (or a span-free `` with no limit). ### 6.3 `"use safe_text"` By default, text that contains a special-token string (a user typing `<|im_end|>`) is tokenized as that special token, exactly like every Jinja chat template does (prompt injection by design). With `"use safe_text";` all dynamic text is encoded with added-token matching off, whether the tokenizer marks the token special or not (DeepSeek's `<|User|>`, GLM's `` and both models' `` are not special); special tokens can then only come from the view's own literal text and `tok.*` values. This holds for every run, including runs after the start on a position-dependent tokenizer (6.1 rule 1), and is the browser's `textContent`: text is never markup. `mistral_common` (Mistral Small 3.2, Magistral) behaves this way, and those two views need the directive to match it. ### 6.4 Special tokens are values, not text (`"use strict_tokens"`) A special token is a value of its own kind, never a string: - `tok.im_start`, `tok.think_end`, `tok.User` (alias of the token text: `<|x|>`, `<|x|>`, `[x]`, `` give `x`; a leading `/` gives `x_end`), or `tok["<|im_start|>"]`. - `` as a JSX element, `${tok.im_start}` inside `` p`...` ``. - `tok.x + "text"` gives a **token fragment** (tokens and text in order). Fragments render as their parts. `.trim()`, `.trimStart()`, `.trimEnd()` and `.join()` keep the tokens. Read-only questions (`startsWith`, `endsWith`, `includes`, `indexOf`, `lastIndexOf`) look at the text the fragment stands for. A fragment joins only with text: `fragment + [1]` is a TypeError (convert the value first with `String(x)` or `JSON.stringify(x)`), so a stray object never prints into a prompt. `isFragment(x)` tests for one. - `tok.x.text` is the token's string and `tok.x.id` its id, for questions about data (`content.split(tok.think_end.text)`). Text from `.text` is still text when it is rendered. - `tok.x` for a token the tokenizer does not have is an error, not empty text. - `textOrToken("")` is the token if the tokenizer has it, else the same plain text. Use it only in a module shared by models that differ here (Qwen2.5 has no `` token, Qwen3 does; GLM-Z1 has no `` token, GLM-4.5 does). This is the one place where a special-token string may appear in a strict file. With `"use strict_tokens";` at the top of a file, any special-token string of the loaded tokenizer written as text (in `` p`...` ``, a string, JSX text, or a template) is a load error that names the `tok.` value to use. All 57 reference views use it (the demos too). Data text that contains a special-token string still becomes that token, as in the official templates, unless the view also says `"use safe_text"` (6.3). ## 7. Expressions and statements The semantics are JavaScript's (`src/jsvalue.rs`, `src/jsbuiltins.rs`, `src/jsmath.rs`). `tests/js_semantics.rs` checks expressions against what JavaScript prints, and `tests/js_differential.rs` runs a corpus (`tests/js_corpus/*.js`) and seeded random programs (`tools/jsgen.mjs`) through both the views and Node, and requires the same printout. The few differences left are listed in 7.3, and what is not supported fails with an error (7.4). - Literals: numbers (`12`, `1.5`, `.5`, `1e3`, `0x1F`, `0o17`, `0b101`, `1_000`; an integer beyond 2^53 is the double JavaScript reads), `NaN`, `Infinity`, strings (`"s"`, `'s'`, template literals; escapes `\n \t \r \v \b \f \0 \xHH \uHHHH \u{H..}` and line continuations), `true false null undefined`, arrays, objects (`{k: v, "k2": v, 3: v, [expr]: v, short, f(x) {..}, ...spread}`), regex literals (7.1), functions (`x => e`, `(a, b = a) => { ... }`, `async (a) => e`, `({a, b}) => ..`, `([a, b], {c: d = 1}) => ..`, `function (x) { .. }`). - Operators, with JavaScript's precedence: `+ - * / % **`, unary `- + ! ~ typeof void delete`, `++`/`--` (prefix and postfix), `== != === !==`, `< <= > >= in instanceof` (the built-in constructors: `Array`, `Object`, `Error` and its kinds, `RegExp`, `Function`), `& | ^ << >> >>>`, `&& || ??` (`??` does not mix with `&&`/`||` without parentheses, as in JavaScript), `c ? a : b`, `,`, assignment as an expression (`a = b = 0`, `x += 1`, `o.k ??= v`, all of `+= -= *= /= %= **= <<= >>= >>>= &= |= ^= &&= ||= ??=`), `x.k`, `x[i]`, `f(a)(b)`, optional chains (`x?.k`, `x?.[i]`, `x?.f()`, `f?.()`: a nullish link makes the whole chain `undefined`), `new Error(..)`, spread in arrays, objects, calls (`f(...xs)`) and element attributes (``: a later attribute of the same name wins, as in JSX). An error stringifies as `Name: message`. - `+` concatenates when either side is a string; `[1, 2] + ""` is `"1,2"`. Numbers are IEEE doubles (`7 / 2` is `3.5`, `-7 % 3` is `-1`, `0 * -1` is `-0`, `2 ** 53 + 1` is `2 ** 53`); `2.0` prints as `2`, and numbers print as JavaScript's `Number.prototype.toString` (`1e+21`, `1e-7`). `==` and `<` convert arrays and objects to their strings first. - Truthiness is JavaScript's: `[]` and `{}` are truthy; `0`, `""`, `NaN`, `null`, `undefined` falsy. `===` compares objects and arrays by identity. - `a[-1]` is `undefined` (use `a.at(-1)`); `a[1.0]` and `a["1"]` are `a[1]`. Reading a property of `null` or `undefined` is a TypeError, as is `for...of` over a non-iterable (an object, `undefined`). Object keys keep JavaScript's order: integer-like keys first, ascending, then the others in insertion order (objects from the host's JSON keep the host's order). - Statements: `const`/`let`/`var` (with nested array and object patterns, defaults, holes, renames and `...rest`), blocks `{ .. }`, `function` declarations (hoisted to the top of their block), `if/else`, `for (const p of xs)`, `for (const k in obj)`, `for (init; cond; update)` (any parts, several declarators, `,` updates), `while`, `do..while`, labels on loops and blocks with `break label` / `continue label`, `switch` (with `===`), `try/catch/finally` (`catch` binds the thrown value itself; a failed operation binds an error (an instance of its class: `name`, `message`, `stack`) with JavaScript's error name), `throw`, `return` (a line break after `return` returns `undefined`), `break`, `continue`. Statements end at `;`, a line break, or `}` (automatic semicolon insertion), so `return x y` is a syntax error rather than two statements. A `const` cannot be reassigned (a TypeError, as in JavaScript). Loops are bounded at 10,000,000 iterations. - Functions are closures: a function shares the variables it uses with the scope that declared them, so it sees later assignments, its own assignments are seen outside, and closures made together share state (`makeCounter()`); each iteration of `for (let ..)` has its own copy. A `let`/`const` read before its declaration ran is a ReferenceError in a block that declares a function (elsewhere see 7.3). - Arrays and objects are references, as in JavaScript: `const b = a; b.push(1)` changes `a`; a function that changes its parameter changes the caller's object; `for..of` over an array sees what its body pushes. `Object.freeze(o)` (shallow), `Object.isFrozen(o)` and `structuredClone(v)` (a deep copy; shared parts stay shared, a cycle stays a cycle; a function is a `DataCloneError`) are JavaScript's. - **`Map` and `Set`** are JavaScript's: `new Map(entries)`, `new Set(values)`, `get set has delete clear forEach size`, `keys values entries`, insertion order, SameValueZero keys (objects by identity, one `NaN`, `-0` is `0`), iteration (`for..of`, spread, `Array.from`, destructuring, `Object.fromEntries`) that sees entries added during it and skips those deleted before it reaches them, `Map.groupBy`, `instanceof`. `JSON.stringify` writes one as `{}`, as JavaScript does. A Map or Set in state is the runtime's: `set`/`add`/`delete` on it are a `TypeError` that says to copy it (`new Map(m)`). `keys()`, `values()` and `entries()` return arrays (JavaScript's return iterators: their `.next()` is not there, an error rather than a different value). - **Errors** are instances of the built-in error classes: `message` and `stack` are own and not enumerable (`Object.keys(e)` is `[]`, `JSON.stringify(e)` is `"{}"`), `name` comes from the class, `e instanceof Error` holds for every kind (and for the runtime's `AbortError`, `TimeoutError`, `InvalidStateError`, ...); a failed operation throws one. A regex's `lastIndex` is JavaScript's: a global regex's `exec`/`test` start at it and move it, a literal makes a new RegExp each time it is evaluated, and a frozen (kept) global regex cannot `exec`. - **`this` and classes** are JavaScript's. A `function`, a method (`{m() {..}}`, a class's) and an accessor bind `this` when called (`o.m()`'s receiver, else `undefined`); an arrow takes the enclosing one. `f.call(t, ..)`, `f.apply(t, [..])`, `f.bind(t, ..)`. `class A extends B { constructor(..) { super(..); this.x = .. } m() {} get g() {} set g(v) {} static s() {} static k = 1; f = 1; #p = 1; #m() {} }`, `new`, `super(..)` and `super.m(..)`, `instanceof`, `extends` of a class or of a built-in error (`class NotFound extends Error {}`): fields initialize in JavaScript's order (a base class's before its constructor, a derived class's right after `super(..)` returns); methods and accessors live on the prototype (not own, not enumerable: `Object.keys`, spread and `JSON.stringify` see only the fields); private members are invisible to them and checked (`o.#x` on an object of another class is a `TypeError`). Private names belong to their lexical class; base and derived `#x` slots are distinct, and a public `o["#x"]` property cannot access either. Extracted methods retain the home class used by `super`, while their receiver still comes from the call. Static members are inherited. A module-level class definition has a stable type brand owned by its program, so an instance kept in state stays `instanceof` that definition on later renders. Class values and static initializers are evaluated afresh with each render's inputs, as module consts are; class objects are not shared between instances or renders. Like every module const the value is frozen (`A.count++` on a module-level class is a `TypeError`). A class created inside a function has fresh type identity each time it is evaluated. Replaying a task reconstructs that same evaluation and preserves its retained reference identities. - **Native methods keep their defining identity.** Borrowing a method with `call`, `apply` or `bind` changes its receiver, not which operation runs. Array methods share their algorithms with object arraylikes and read items when visited; callbacks receive their supplied `thisArg`. Branded methods such as Map/Set access and RegExp execution reject incompatible receivers. Sparse array results remain unsupported and fail explicitly. - **What the runtime keeps is frozen.** A value written to state (a setter's value, an updater's result, `useState`'s initial value), the props, module consts, `harness`, what `memoScan` keeps (its items, deps, init, and each step's state and out), a kept turn, and everything a closure kept past its run (a render's handlers and effects, an effect's cleanup) captured are frozen deeply. Writing to a frozen value is a `TypeError` (as writing to a frozen object is in strict mode); copy it to change it (`{...s, k: v}`, `[...xs, x]`, `structuredClone(s)`). A value that contains itself cannot be kept in state (a `TypeError`, as `JSON.stringify` refuses it). Cycles in a run's own objects (`o.self = o`, an object whose method refers to it) are freed when the run ends. **Methods.** Strings: `trim trimStart trimEnd toLowerCase toUpperCase startsWith endsWith includes indexOf lastIndexOf replace replaceAll split slice substring substr at charAt charCodeAt codePointAt padStart padEnd repeat concat localeCompare match matchAll search toString` (replacement patterns `$$ $& $\` $'` and replacer functions with string patterns too). Arrays: `map filter find findIndex findLast findLastIndex some every includes indexOf lastIndexOf reduce reduceRight forEach join concat slice at flat flatMap keys values entries toString push pop shift unshift splice sort reverse fill toSorted toReversed toSpliced with` (`sort` is V8's TimSort, so even an inconsistent comparator orders as in Node). Numbers: `toFixed` (JavaScript's rounding of the exact value) and `toString(radix)`. Objects: `hasOwnProperty`, `toString`, and a function-valued member is callable (`game.press(b)`). **Globals.** `Math.*` (all functions and constants; `random` is an error: renders are deterministic; use `await harness.random()` in a handler), `Object.keys/values/entries/ fromEntries/assign/is`, `JSON.stringify` (with a replacer function or key list, `toJSON`, and a number or string indent) and `JSON.parse` (with a reviver), `Array.isArray/from/of`, `Array(a, b, ..)` and `Array(n).fill(x)`, `Number.isInteger/isSafeInteger/isFinite/isNaN/parseInt/ parseFloat` and the `Number` constants, `String.fromCharCode/fromCodePoint`, `String() Number() Boolean() parseInt parseFloat isNaN isFinite`, `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `URIError`, `EvalError`, `RegExp`. Built-in functions are values too (`xs.map(Number)`, `xs.filter(Boolean)`, `xs.map(Math.abs)`). Global intrinsics obey lexical bindings: local variables, parameters and imports named `Math`, `JSON`, `Array`, `Number`, or `harness` take precedence over the platform's values. This also applies to global constants such as `undefined`, `NaN` and `Infinity`, and includes lexical declarations before their initializer runs (the temporal dead zone). Supported intrinsic constructors and namespace methods are first-class values: aliases and computed property reads use the same resolution as direct calls. This does not add unsupported JavaScript APIs or sparse arrays; `Array(n).fill(x)` remains a supported dense construction. **Affordance builtins.** `useState(init)` (a function initializer is called only when the hook mounts), `isFragment(x)`, `isPlainObject(x)`, `countTokens(text)` (tokens under the bound tokenizer), `tokens(ids)` (exact ids as a fragment), `textOrToken(s)` (6.4), `formatNow(fmt)` (the render clock, strftime format), `asChatTools` (5.1), `turnStrategy`, `templateInput`, `messagesJson`, `asContent`, and `harness.*` in handlers (`docs/RUNTIME.md`). **The clock and the console.** `Date.now()` is the instance's clock in milliseconds: the journal's in a handler (as `harness.now()`), so a replay reads the same time, and the render's in a render. There is no `Date` object; `formatNow(fmt)` prints the clock. `console.log`, `info`, `warn`, `error` and `debug` write a line for whoever watches the app, as a page's console is its DevTools view: to the instance's trace (`{t: "console", level, text}`, which `affordance host` and an observer also see) or, under `affordance render`, to stderr. Strings print as they are and other values as JSON. A console line is never state and never journaled; a render writes its lines when it is evaluated, not when a cached one is reused. A missing browser or Node global (`setTimeout`, `process`, `window`, `require`, `localStorage`) is a ReferenceError that names what to use instead. **Exact template text.** The official chat templates are Python; where they print a value, the views print the same characters with explicit helpers: | helper | prints | |---|---| | `formatJSON(x, {indent, separators: [item, key], ascii, sortKeys})` | Python `json.dumps` text (defaults `", "` / `": "`; `","` with an indent) | | `formatPythonValue(x)` | Python `str(x)`: `True`, `None`, `1.0`, `{'a': 1}`; laid-out text (a fragment) is itself, tokens and spans kept | | `formatPythonRepr(x)` | Python `repr(x)` | | `trimChars(s, chars)`, `trimStartChars`, `trimEndChars` | strip any of `chars` from the ends | | `capitalize(s)`, `titleCase(s)` | Python `str.capitalize()` / `str.title()` | Where a template fails on a value of the wrong type, its view fails at the same point with an explicit check: `views/common/checks.tsx` has `textOperand`, `mappingEntries`, `templateLength`, `templateItems` and `templateTruthy` (Python's `str + x`, `.items()`, `|length`, `for` and truthiness), each throwing `new TypeError(..)` where Python would. ### 7.1 Regular expressions (`src/jsregex.rs`) JavaScript regexes on the Rust `regex` crate. `/src/flags` literals (compiled at load time; `/` after an operand is still division) and `new RegExp(src, flags)` / `RegExp(src, flags)` make a `RegExp` value with `source flags global ignoreCase multiline dotAll unicode lastIndex`. Methods: `re.test(s)`, `re.exec(s)`, and with a regex argument `s.match(re)` (all matches with `g`), `s.matchAll(re)` (an array of match arrays; needs `g`), `s.search(re)`, `s.replace(re, str|fn)`, `s.replaceAll(re, ..)` (needs `g`), `s.split(re, limit)` (captures spliced in). Replacement strings take `$$ $& $\` $' $n $nn $`; a replacer function gets `(match, p1.., offset, string[, groups])`. Unmatched groups are `undefined`; no match is `null`. Flags `g i m s u`. Semantics follow JS: `.` excludes all line terminators unless `s`; `\d \w \b` are ASCII; `\s` is the JS whitespace set; `[]`/`[^]`; global iteration steps one code point past empty matches. Differential (27 patterns x 15 strings, 7 operations each): equal to Node 22 except `m`-mode `^`/`$` next to U+2028 (2 of 405 cases). With `m`, `^`/`$` treat `\n` and `\r` as line ends, not U+2028/U+2029, and do not match between `\r` and `\n`. **Not supported** (a load or construction error, never a silent difference): lookahead `(?=` `(?!`, lookbehind `(?<=` `(?`, flags `y d v`. Other differences: values are immutable, so `lastIndex` stays 0 and `exec`/`test` search from the start; match arrays are plain arrays without `index`/`input`/`groups` (use `search` for the index); indices are code points, as everywhere in the language; `i` folds Unicode case as JS does with `u`. ### 7.2 Async functions and parallel effects `async function f(..) {..}` (top level, also `export`), `const f = async (..) => ..` and async arrows in expressions. `await` is an operator only in an async function's own body (not in a nested plain function, a class field or a module initializer): elsewhere it is a load error, as in JavaScript. Inside a handler, calling an async function runs its synchronous prefix immediately and returns a promise. Every `await`, including `await 0`, suspends that function and queues its continuation. Each instance has one FIFO microtask queue for awaits, reactions, adoption and combinators. The current input's listeners run their synchronous prefixes before the queue drains. `await Promise.all(xs.map(x => f(x)))` starts every `f` before parking; the host may serve their requests concurrently and complete them in any order. Saved frames retain locals, evaluated operands, references and exception/finally state; a resumption never reruns the earlier computation. Recovery reconstructs the same execution by feeding journaled external inputs through this executor. **Promise completions** use JavaScript values: `p.then(f, g)`, `p.catch(g)` and `p.finally(f)` (callbacks run as queued jobs), `Promise.all`, `Promise.race`, `Promise.any`, `Promise.allSettled`, `Promise.resolve` and `Promise.reject`. Combinators accept supported iterables (arrays, strings, Map and Set); an invalid iterable rejects the returned promise with a TypeError. Static methods keep their `Promise` receiver: a detached method requires `.bind(Promise)` or `.call(Promise, ...)`. Reaction registration and settlement determine job order. Already settled inputs enqueue reactions in input order; the resulting promise may still need further adoption or reaction jobs. Thus `Promise.race([Promise.all([0]), Promise.resolve(1)])` resolves to `1`. `race` does not cancel its other inputs. An async function that throws returns a rejected promise. Each promise retains one fulfillment value or rejection reason; repeated awaits preserve reference identity. `Promise.resolve(p)` returns `p` when it is already a promise. `catch (e)` and rejection callbacks receive the original thrown/rejected value, including primitives and `undefined`. Host failures produce Error objects (`name`, `message`, `stack`); typed failures keep their name (`AbortError`, `TimeoutError`, `InvalidStateError`, `TypeError`, `AggregateError`). `Promise.any` rejects with an AggregateError whose `errors` holds the reasons in input order. Non-callable reaction arguments are ignored. `finally` runs when its block really ends, not when the block suspends on a pending request. A handler body is the root even when written `async`; outside handlers (render, plain `act`) async functions run synchronously. An unawaited async call that is still parked when its handler returns resumes when what it waits on settles. Pending promises can be retained in state and observed by another handler. Retaining a promise freezes its exposed completion when it settles, without freezing the suspended function's private locals. Cancelling a task drops its frames and reactions and rejects its outstanding host promises with `AbortError`; completed external operations are not undone. Promise construction with an executor and custom thenables are outside the supported subset. ### 7.3 Differences from JavaScript Each is deliberate (the language's model) or a known limit. Synchronous differences have corpus cases marked `// expect: divergent` in `tests/js_corpus/`. Supported async behavior is compared with Node in `tests/promise_corpus.js` and checked through live execution and journal restore. - **Strings are Unicode code points**, not UTF-16 code units: `length`, indexes, `slice`, `charCodeAt` and the rest count a character outside the Basic Multilingual Plane (an emoji) as one; a lone surrogate (`"\uD800"`) cannot be written. - **The state's keys are also global variables** (a template may read `messages` without declaring it); a name declared nowhere is a ReferenceError, as in JavaScript (`typeof x` of one is `"undefined"`). A `let`/`const` read before its declaration ran is a ReferenceError only in a block that contains a function (where declarations are hoisted); elsewhere the name still refers to the enclosing scope. - **Functions have no `arguments`, `length` or `name`**, and prototypes are not values (`Object.getPrototypeOf`, `__proto__` and `X.prototype` are not supported). The members objects inherit are JavaScript's for `in` and for reading them as values (`"toString" in {}`, `o.hasOwnProperty.call(p, k)`, `"push" in []`). - **Handlers start from the render's values.** Within one render or one handler run, closures share variables as above. A handler kept from a render (and every closure it reaches) starts each invocation from the variables it captured as that render left them (frozen). Suspended invocations retain their own locals, and durable recovery reproduces them from inputs. Assigning such a variable is a `TypeError` naming it (the assignment would not last past the run): keep it in state. - **Numbers from JSON**: an integer beyond 2^53 (in the state or `JSON.parse`) keeps its exact digits when printed (Python templates print them so); arithmetic on it uses the double. - **Math**: `Math.sin`, `Math.cos` and `Math.pow` / `**` with an inexact result can differ from V8 in the last bit for some arguments (V8 uses its own implementations; the others match V8's fdlibm bit for bit). - **`localeCompare`** follows the root collation for ASCII text; other text compares by code points. - **Sizes are bounded per step** (fuel counts steps, not bytes): a string longer than V8's 536,870,888 code points is V8's `RangeError: Invalid string length`, however it is built (`+`, templates, `join`, `concat`, `repeat`, `pad*`, `replace`, `JSON.stringify`). A step that makes or grows an array by many items at once (`Array(n).fill`, spreads, `concat`, `push(...xs)`, `split`, `match` with `g`, `Array.from`) throws `RangeError: Invalid array length` past 10,000,000 items, where V8 allows about 134 million. Child lists nest at most 400 levels; a child list inside itself is a TypeError (React overflows the stack). ### 7.4 Not supported Each raises an explicit load or runtime error, never a silently different value: getters and setters in object literals (classes have them), static blocks, computed class member names, function source reflection, primitive boxing through borrowed Object methods, generators, `yield`, `arguments`, `eval`, `with`, `import()`; `WeakMap`, `WeakSet`, `Date` (other than `Date.now()`), `Symbol`, `BigInt` (`10n`), `Proxy`, `Reflect`, `Intl`, `Promise` constructors other than the static methods (7.2), custom thenables; `new` of a function (only classes and the built-in constructors); sparse arrays (holes `[a, , b]`, `Array(n)` without `.fill`, assigning past the end, `delete xs[i]`, growing `length`); named properties on arrays; tagged templates other than ``p`..` ``; the regex features in 7.1; legacy octal literals and escapes. ## 8. Errors A render either returns ids or an error. `throw new Error(msg)` is an explicit raise (`raise: msg`); `throw new TypeError(msg)`, a failed operation (a property of `undefined`, a non-iterable) and an unknown name are type errors. `affordance check` counts "both raise" as a match and compares the kind and the message of explicit raises. Extra props on `` travel as the span's meta: a template adapter's zero-token `` tells a runtime how to read the model's replies (`docs/RUNTIME.md`). A render error keeps its message on the first line and appends the view stack, one ` at Name (file:line)` frame per function or component (innermost first), then ` while rendering TurnStrategy log[i] (kind "...")` when a template turn from the log is in scope. `affordance::error_message` strips them; `affordance check` compares messages only. ## 9. Not built yet Embedding another instance's screen inside a parent, streaming/incremental renders that reuse a previous render's ids, image sizing from pixel dimensions (`` takes `tok_size` only), and `{% generation %}` assistant masks (spans can provide them; not wired to the HF API). ## 10. The program is an event loop: the harness The view renders; the *program* is a loop. The runtime is the browser's event loop, and the loop that calls the model is the app's own: an instance is a page that renders its state, runs listeners when events arrive, and calls its model when its code decides to (`spec/HARNESS.md` §3). What the language contributes: - **Hooks.** `useState(init, "name")` (a named field of the instance's state); setters called in a task apply once to live state, in order (a function to the current value). `useEffect(setup, deps)`: after a render whose `deps` changed (React's rule: an item differs by `Object.is`, or the length differs; no deps array runs it after every render), the previous cleanup runs, then `setup` (a synchronous function that may start async work and return a cleanup). `useRef(init, "name")` returns `[read, write]`: storage kept with the instance's hooks (journal, images) that does not render. `read()` works in handlers only; a render that reads a ref fails (React's rule for `ref.current`), so a handler's writes to refs alone leave the last evaluated document current and the page is not evaluated again for them. `views/lib/log.tsx` keeps its open turn's bookkeeping in one (`log.isOpen()`, `log.held()`). - **Listeners and tools.** `useEvent(name, listener)` or `` (an event arrives; a `useEvent` listener is a zero-token span after the root), a ``'s `onX` (its child called back) and `onStop`; `` tools run when an output is dispatched (`dispatch(output)`). All reach the world through harness requests. The journal records external inputs for recovery. - **A tool is a function.** `` (`views/lib/tool.tsx`) lowers to ``: its name is `nameOf(f)` (a declaration's name, or the `const` it is bound to), its description `docOf(f)` (the summary of the `/** */` comment before the declaration, up to the first `@tag`), its argument schema `schemaOf(f)`, the props parameter's type as JSON Schema (`src/schema.rs`: string, number, boolean, arrays, object types with optional members, string-literal unions as enums, `T | null`, `type` and `interface` aliases from the function's defining file; properties and `required` in declaration order; anything else is an error naming the type). - **A model call is a fetch of a view** (`model()` for the page's own view, or `model()` for another): a prompt is written in the language like any view, including its template; the output is data, and `dispatch(output)` runs its calls. A log of turns is app state (`views/lib/log.tsx`), and `views/lib/loop.tsx` writes the two common loops over it: `useLoop(log)` (ask, act, repeat) and `useConversation(log)` (answer whatever is unanswered). Not built: compute allotments as properties of a view (`style={{ maxCallsPerMinute: 6, computeShare: 0.2 }}`, enforced by the harness), and decision readouts as a model binding's mode. ## 11. Migrating from the Python/Jinja dialect Until 2026-10-13 the language had Python/Jinja values (`str()`, `len()`, slices, keyword arguments, Python truthiness) and an `.avl` syntax. Both are gone: the surface is the one in sections 2-8, with the supported semantics and differences stated there. There is no Python dialect behind a pragma. The directives that mean something are `"use strict_tokens"` (6.4) and `"use safe_text"` (6.3); `"use js"` from the transition is accepted and does nothing. The table below is generated from `src/migrate.rs`, the table the parser, the runtime and `affordance lint` use. A Python idiom fails with its replacement in the message, at load time for syntax (`Python slice syntax ... use x.slice(a, b)`) and at run time for names (`ReferenceError: enumerate is not defined (... use xs.entries())`). `affordance lint [paths]` flags all of them ahead of time (section 12). | Python / Jinja | JavaScript | |---|---| | slices `x[:160]`, `xs[-5:]`, `t[a:b]`, `s[::-1]` | `x.slice(0, 160)`, `xs.slice(-5)`, `t.slice(a, b)`, `[...s].reverse().join("")` | | keyword arguments `dict(r, reasoning="", ids=null)`, `f(x, indent=4)` | `{...r, reasoning: "", ids: null}`, `f(x, {indent: 4})` | | `k not in o`, `x not in xs`, `a and b`, `a or b`, `not a` | `!(k in o)`, `!xs.includes(x)`, `a && b`, `a \|\| b`, `!a` | | `list + [x]`, `a + b` on lists | `xs.push(x)` (or `[...xs, x]`), `[...a, ...b]` | | `True`, `False`, `None` | `true`, `false`, `null` | | empty list / dict is false | `xs.length > 0`, `Object.keys(o).length > 0` (`[]` and `{}` are truthy) | | `x is defined`, `x is none` | `x !== undefined`, `x === null` | | `str(..)` | String(x), or a template literal `${x}` (formatPythonValue(x) for Python's text) | | `len(..)` | x.length (Object.keys(o).length for an object) | | `int(..)` | parseInt(s) or Math.trunc(x) | | `float(..)` | Number(x) | | `bool(..)` | Boolean(x) | | `repr(..)` | formatPythonRepr(x) | | `json(..)` | JSON.stringify(x), or formatJSON(x, {indent, separators, ascii, sortKeys}) for Python's json.dumps text | | `tojson(..)` | JSON.stringify(x), or formatJSON(x) for Python's json.dumps text | | `parse_json(..)` | JSON.parse(s) | | `try_parse_json(..)` | JSON.parse(s) inside try { } catch { } | | `enumerate(..)` | xs.entries() (for (const [i, x] of xs.entries())) | | `range(..)` | for (let i = 0; i < n; i++), or Array.from({length: n}, (_, i) => i) | | `items(..)` | Object.entries(o) | | `keys(..)` | Object.keys(o) | | `values(..)` | Object.values(o) | | `dict(..)` | {...o, k: v} | | `list(..)` | [...x] or Array.from(x) | | `reversed(..)` | [...xs].reverse() | | `sorted(..)` | [...xs].sort((a, b) => ...) | | `sort_by(..)` | [...xs].sort((a, b) => ...) | | `join(..)` | xs.join(sep) | | `first(..)` | xs[0] | | `last(..)` | xs.at(-1) | | `default(..)` | x ?? d | | `merge(..)` | {...a, ...b} | | `omit(..)` | const {k, ...rest} = o | | `min(..)` | Math.min(...xs) | | `max(..)` | Math.max(...xs) | | `abs(..)` | Math.abs(x) | | `sum(..)` | xs.reduce((a, b) => a + b, 0) | | `any(..)` | xs.some(x => ...) | | `all(..)` | xs.every(x => ...) | | `raise(..)` | throw new Error(msg) | | `trim(..)` | s.trim() | | `strftime_now(..)` | formatNow(fmt) | | `count_tokens(..)` | countTokens(s) | | `is_fragment(..)` | isFragment(x) | | `is_string(..)` | typeof x === "string" | | `is_number(..)` | typeof x === "number" | | `is_int(..)` | Number.isInteger(x) | | `is_bool(..)` | typeof x === "boolean" | | `is_map(..)` | isPlainObject(x) | | `is_list(..)` | Array.isArray(x) | | `is_sequence(..)` | Array.isArray(x) | | `is_none(..)` | x === null | | `is_defined(..)` | x !== undefined | | `is_undefined(..)` | x === undefined | | `.strip(..)` | .trim() (trimChars(s, chars) to strip given characters) | | `.lstrip(..)` | .trimStart() (trimStartChars(s, chars)) | | `.rstrip(..)` | .trimEnd() (trimEndChars(s, chars)) | | `.startswith(..)` | .startsWith(x) | | `.endswith(..)` | .endsWith(x) | | `.lower(..)` | .toLowerCase() | | `.upper(..)` | .toUpperCase() | | `.title(..)` | titleCase(s) | | `.splitlines(..)` | .split("\n") (or .split(/\r?\n/)) | | `.rsplit(..)` | .split(sep) and take from the end | | `.rfind(..)` | .lastIndexOf(x) | | `.isdigit(..)` | /^\d+$/.test(s) | | `.isalpha(..)` | /^[a-zA-Z]+$/.test(s) | | `.isalnum(..)` | /^[a-zA-Z0-9]+$/.test(s) | | `.isspace(..)` | /^\s+$/.test(s) | | `.format(..)` | a template literal `${x}` | | `.items(..)` | Object.entries(o) | | `.keys(..)` | Object.keys(o) | | `.values(..)` | Object.values(o) | | `.get(..)` | o[k] ?? d | | `.append(..)` | .push(x) | | `.extend(..)` | .push(...xs) | | `.count(..)` | s.split(x).length - 1, or xs.filter(y => y === x).length | | `.index(..)` | .indexOf(x) | ## 12. Changing the language The language has consumers outside its own views: agent apps in other repositories import views from this tree. A removal that migrates every view here but not those leaves their agents idle on a view that no longer parses. So: 1. **Syntax or builtins are not removed without migrating every consumer in the same change.** The consumers are this tree's `views/`, `tests/fixtures/`, the docs' code examples, and the out-of-tree directories listed (one per line) in a local, untracked `tools/view_consumers.txt`. 2. **A removal adds its migration** to `src/migrate.rs` (and so to the errors and to `affordance lint`) and to the table in section 11. `tests/lint.rs` fails if the two disagree. 3. **`affordance lint` must be clean** (`tests/lint.rs` runs it over all of the above), and the runtime's acceptance port (`tests/session_agent.rs`) is run against the real agent views before the tree is left in that state: `VIEW= PROPS='{...}' cargo test --release --test session_agent`. 4. **Consumers import, they do not copy.** An app that is a variant of a view in this tree imports it and overrides props (`SessionAgent({...props})`), so it follows the language with its source. --- Source: docs/MIDDLE_LAYER.md Page: https://affordance.rs/docs/middle-layer.html # App, turn strategy, template app Contract authority: [public contracts](../spec/CONTRACTS.md), [language](../spec/LANGUAGE.md) and [harness](../spec/HARNESS.md). The middle layer accepts an app and a replay log. It chooses which observations and interactions appear as turns. A model-template app consumes those turns and owns protocol tokens. `messages` is a compatibility input only at that final boundary. Product rendering and action execution stay in Rust. ```tsx import { TurnStrategy } from "@affordance/strategies/turn_strategy.tsx"; import { Qwen36App } from "@affordance/template_apps/qwen36.tsx"; import { FindBigNumber } from "./find_big_number.tsx"; export default function Main({ numbers, log }) { const app = ; return {app} ; } ``` Create the native app before the template component to keep its hook path stable when switching models. The same example has entry files for Qwen3-Instruct-2507, Qwen3 hybrid thinking, Qwen3.6, Nemotron 3 Nano, Llama 3.1, GLM-5.3 and MiMo-V2.5. The app names and bound tokenizer models are in `views/template_apps/index.json`. Explicit entries import their own family. Runtime apps can instead use `ModelContext` from `views/lib/model.tsx`, with `system`, `log`, `strategy` and screen children; the model binding selects its template and options. The shared registry loads every stock template safely under each registered tokenizer. Selected templates still require their own special tokens. `TurnStrategy` is an ordinary component, backed by Rust's `turnStrategy` builtin. It projects the current app's visible actions after layout, then produces a `TurnPlan`. Capture carries that plan as a typed item. `templateInput` consumes exactly one plan; mixed children and an unconsumed plan fail explicitly. | Policy | Prior screens | External input, model output and committed results | Current screen | |---|---|---|---| | `unrolled` | Every observation | Retained in log order | Final user turn | | `last-turn` | Omitted | Retained in log order | Final user turn | The caller owns the complete log. Neither policy mutates it. Metrics and events stay in that source log without entering the prompt. These are two starting policies; truncation, compaction and folding can replace the selection logic. ```json [ {"kind":"input","content":"Remember the goal."}, {"kind":"screen","content":"Prior observation."}, {"kind":"model","content":"","calls":[{"type":"function","function":{"name":"scroll__notebook","arguments":{"by":20}}}]}, {"kind":"action","name":"scroll__notebook","result":"ok"}, {"kind":"metric","prefill_ms":7} ] ``` `model.reasoning` maps to the template's reasoning field. Action records may carry `tool_call_id`. Any record may carry exact token `ids` for its body instead (a `model` record: the generated ids, rendered after the generation prompt), and the current screen is the `turn:current` span of the render; see `docs/HARNESS_GUIDE.md` for the record schema, the ids semantics and which template apps support them. A runtime can attach timing and replay metadata to every record. The app owns this log as state, commonly through `useLog()`; the runtime journals inputs and reconstructs app state on replay. An app may choose its own history schema. `useLog()` exposes render-time `records`/`open` snapshots; its methods operate on current hook state. `release(upto, keep?)` accepts a nonnegative safe-integer exclusive boundary, clamped to the records present when it runs. It stores the unreleased prefix before replacing its payloads with skeletons. Overlapping releases retry from the committed prefix; a failed write leaves those records intact. `read(from, to)` snapshots current records and their batch metadata together, then restores any released payloads. Append, release and read may therefore run sequentially in one handler without waiting for another render. Turn content accepts strings and native fragments. A current fragment holds its original token IDs, spans and handler references. Token positions are rebased inside the final model prompt. Historical fragments keep IDs and diagnostic ranges, but lose callback and projected-tool metadata, so past screens cannot dispatch past handlers. The live tool table comes only from the current app. Outer clipping of already-projected live actions raises; put budgets inside the app. `isFragment`, fragment-aware `trim`, and `asContent` let templates preserve these values without flattening them into text. Native boundaries intentionally preserve IDs. Arbitrary leading/trailing whitespace can still differ from HF's concatenated-string tokenization or string trimming; the existing adapter boundary study applies. FindBigNumber places row separators before rows and a final newline outside the notebook budget. This avoids a newline merge across the budget boundary in its complete screen. Full-screen token exactness is audited independently through Qwen3.6 and Nemotron 3. Clipped screens preserve native IDs/actions; arbitrary clipped screens are not claimed to equal full-string HF tokenization. The FindBigNumber app has a goal box, a scratchpad, a token-budgeted notebook with 1,024 seeded random numbers and scroll, an answer field, and submit. Its handlers do not know the maximum. Submit returns the current answer as state. The tests exercise real handlers, switching templates with preserved hook state, historical fragment isolation and visibility after layout. This is a rendering and plumbing result, with no learning claim. ```sh cargo build --offline --release --example middle_demo ./target/release/examples/middle_demo --sample --family qwen36 --seed 20261008 --budget 96 ./target/release/examples/middle_demo --sample --family nemotron3 --seed 20261008 --budget 96 # stdin: {"states":[STATE],"calls":[[{"name":"scroll__notebook","arguments":{"by":100}}]]} ``` `middle_demo` returns JSON containing next state, IDs, spans and diagnostic text. The optional `--tokenizer PATH` binds it to an external model tokenizer. Model inference lives in an adapter, not this driver. Reference tests require the original cached tokenizer files, as the rest of the repository does. Fetch the pinned NVIDIA files, then generate references on CPU: ```sh .venv/bin/python tools/fetch_middle_reference.py --destination ref/middle_models/nvidia__NVIDIA-Nemotron-3-Nano-30B-A3B-BF16 TOKENIZERS_PARALLELISM=false .venv/bin/python tools/middle_references.py cargo test --offline --release --test middle cargo test --offline --release ``` The NVIDIA source revision and file hashes are recorded in `ref/middle_out/nemotron_source.json`. Expected IDs come from its official HF template. Reference Python constructs bodies, logs and schemas independently; it never consumes Rust-rendered text or IDs. The original 57-view index, references and conformance tests are unchanged. The new tests cover 56 composition cases, 16 complete 1,024-number prompts and 120 separate NVIDIA thinking/tool/history cases. `useLog` owns one turn through calling, ready, dispatching and closed states. Its public `open` remains a boolean. Pass the output of `log.call()` to `log.dispatch(out)` or `log.close(out, why)`; output ownership travels with that value. Await pending work before closing. Old closes cannot close a newer turn, and stale/cross-log dispatches fail explicitly. The core's independent `model()` calls and idempotent `dispatch()` remain available to apps that implement another policy. The runtime and `useLog` library supply model/dispatch records, and the session app demonstrates folding. Image/audio preprocessing remains integration-specific. Experimental alternate engines belong to the separate research project; the shipped framework uses the Rust interpreter. The portable queued Qwen3-4B integration adapter checks native/HF equality before each frozen model call. Its full-screen task does not measure scrolling ability or establish a learning advantage. --- Source: docs/ADAPTERS.md Page: https://affordance.rs/docs/adapters.html # Native apps in model chat formats Contract authority: [public contracts](../spec/CONTRACTS.md), [language](../spec/LANGUAGE.md) and [harness](../spec/HARNESS.md). 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. ```tsx import { Notebook } from "./notebook_app.tsx"; import { Qwen3App } from "../adapters/qwen3.tsx"; export default function NotebookChat({ numbers, question }) { const app = ; return {app}; } ``` 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](RUNTIME.md) and [HARNESS.md](../spec/HARNESS.md). --- Source: docs/REPLY_PARSING.md Page: https://affordance.rs/docs/reply-parsing.html # Reading model replies Contract authority: [public contracts](../spec/CONTRACTS.md), [language](../spec/LANGUAGE.md) and [harness](../spec/HARNESS.md). Rendering turns state into a model's tokens; reply parsing turns the model's tokens back into thinking, content and tool calls. Every chat view in `views/index.json` has a row in `views/reply_protocols.json` that names its reply syntax, the tokens that end a reply, and whether the generation prompt ends inside an open thinking block. The parsers live in `src/reply.rs` (`Syntax`, `parse_reply_typed`, `Problem`) and `src/reply_formats.rs`. | Syntax | Families (entry views) | Tool calls | Thinking | |---|---|---|---| | `hermes` | Qwen2.5, QwQ, MiMo-7B, Qwen3 (+ Thinking/Instruct-2507), Kolibri | `\n{"name", "arguments"}\n` | `` | | `xml` | Qwen3.5/3.6/3.8, Qwen3-Coder, MiMo V2/V2.5/V2.5-Pro/V2.6 | `v` (MiMo V2.6 also `{json}`) | `` | | `glm` | GLM-4.5 … GLM-5.3 | `xkv` | `` | | `deepseek_v3` | DeepSeek V2.5, V3, V3-0324, R1, R1-0528 | `<|tool▁call▁begin|>function<|tool▁sep|>x` + a ```` ```json ```` block | `` | | `deepseek_v31` | DeepSeek V3.1 | `<|tool▁call▁begin|>x<|tool▁sep|>{json}<|tool▁call▁end|>` | `` | | `dsml` | DeepSeek V3.2, V4, V4.1 | `<|DSML|invoke name="x"><|DSML|parameter name="k" string="true">v` | `` | | `llama31` | Llama 3.1, 3.2 | `{"name": "x", "parameters": {…}}` (one call) | none | | `llama4` | Llama 4 | `[x(k="v"), y(k=1)]` | none | | `mistral_list` | Mistral 7B v0.3, Nemo | `[TOOL_CALLS] [{"name", "arguments"}, …]` | none | | `mistral_args` | Ministral 3, Mistral Small 3.2 / 4, Medium 3.5, Magistral | `[TOOL_CALLS]x[CALL_ID]id[ARGS]{json}` | `[THINK]…[/THINK]` | | `harmony` | gpt-oss | `<|start|>assistant to=functions.x<|channel|>commentary json<|message|>{json}` | `analysis` channel | | `atem` | Muse Glimmer | `<|start|>assistant to=x<|message|>…` | `to=self` messages | | none | DeepSeek LLM, Llama 2, Llama 3, Qwen2 (no tools); GLM-4 classic (`glm-4-9b-chat`, GLM-4-0414, GLM-Z1-0414: the model's call format is not confirmed from the references) | | | Every parser reports the same `Problem`s: `no_action`, `multiple_actions`, `prose_before_action`, `text_after_action`, `unclosed_think`, `bad_syntax`, `unknown_action`, `bad_args`, `cut_off`. A reply cut off before its thinking closes is thinking (`unclosed_think` + `cut_off`), never content; a call cut off mid-way is never returned. Syntaxes whose values arrive as text (`xml`, `glm`, `atem`, `llama4`) are typed by the action's JSON schema (`"3"` → `3`; Python literals such as `{'a': True}` are read too). Decode replies with special tokens kept (`decode(ids, false)`): several families write their markers as special tokens (Mistral `[TOOL_CALLS]`, gpt-oss channels, Glimmer headers, Llama `<|python_tag|>`, DeepSeek V3.2 `|DSML|`). ## Adding a family 1. Write the view (`views//…`) and add it to `views/index.json` (conformance as usual). 2. Add its row to `views/reply_protocols.json`: `syntax` (an existing one, or `null` with a `reason`), `stop` (the token strings that end a reply), `in_think` (does the default generation prompt end inside an open thinking block?). 3. If the format is new, add a `Syntax` variant (`src/reply.rs`, name in `Syntax::NAMES` / `Syntax::parse`) and its parser in `src/reply_formats.rs` returning `Parts`. 4. Run `cargo test --release --test reply_coverage`. It fails for a view without a row, and it round-trips the parser through the view: it renders a conversation without and with an assistant turn holding tool calls (one call, two calls, string arguments, with reasoning) and parses what the template wrote for the model's turn. Where history is rendered differently from a fresh generation, the calls are checked in their history form. A rendering that cannot be read back for a template-specific reason goes in the test's `KNOWN_LOSSY` list with that reason. 5. To run the family as an agent, its template app (`views/template_apps/`) declares the same protocol in a zero-token `` (checked against the table by `reply_coverage`), passes the family's special tokens (`bos_token`, …), and calls `templateInput(children, system, true, {alternate})`. - **Generated turns.** The view renders a `generated` assistant turn (`m.ids`, with the turn's calls as `tool_calls` metadata) as exactly what its generation prompt was at that point, then `tokens(m.ids)`, then the turn end only if the ids lack it. It updates its own state as after an assistant turn (DeepSeek's flags after tool output, gpt-oss's last tool). Every chat view does this. `m.ids` is a token run that reads like a list of ids (HARNESS_GUIDE section 5). - **Laid-out content.** A screen is a fragment: string operations on content (`.includes`, `.split`, iterating it as parts) must let fragments through as text. - **`alternate`.** For a format that requires alternating roles (Llama 2, Mistral v0.3), or places tools by the last user turn (Mistral Small 3.2): a user turn right after another user turn or a tool result joins it. - **`system` null.** For a format that moves the system prompt every turn (Mistral v0.3 puts it in the final user turn): no system turn. `tests/template_sweep.rs` runs every chat view this way, through a real agent, for three turns (a tool loop, or a text loop for a family without tool calls). It checks that each reply parses, that each prompt is an exact append of the last plus the reply, and that the turn boundary after a reply is the template's own (no doubled or missing stop). It reports the configuration each family needs. --- Source: docs/RUNTIME.md Page: https://affordance.rs/docs/runtime.html # The agent runtime Contract authority: [public contracts](../spec/CONTRACTS.md), [language](../spec/LANGUAGE.md) and [harness](../spec/HARNESS.md). An agent app is one view file. The runtime runs many agents of it, the way a browser runs many tabs: it renders each app to token ids, sends them to a model server, reads the reply, runs the app's handlers, and serves the effects they ask for. The runtime knows nothing about any app. It defines no app-specific tool vocabulary, state schema, template tokens or fold policy. - Contract: [HARNESS.md](../spec/HARNESS.md); ownership: [CONTRACTS.md](../spec/CONTRACTS.md). - Code: `src/runtime/`. - Binary: `src/bin/agents.rs`. - Example apps: - `views/demo/runtime/agent.tsx` (minimal); - `views/agents/session.tsx` (a long-session agent, with folding). - Tests: `tests/runtime.rs`, `tests/session_agent.rs`, `tests/agents_bin.rs`. ```sh affordance run # the deployment in affordance.yaml (here or in a parent directory) affordance run --resume # continue from its journals affordance config # print the deployment a run would use ``` ## The deployment file A deployment (`affordance.yaml`) names the app, its model bindings, its machines and its agents. `affordance run [PATH]` reads PATH (a file, or a directory holding `affordance.yaml`), or else `affordance.yaml` in the current directory or the nearest parent with one. It is YAML (a JSON deployment is valid YAML too); unknown keys are errors. - **Layers**, lowest first: the file; `affordance.local.yaml` beside it, if present (for values that are not committed: a URL, a count); each `-f FILE`; each `--set KEY=VALUE` (KEY a dotted path, `agents[0].max_turns` for a list element; VALUE parsed as YAML); `--dir` and `--app`. Maps merge key by key, lists and scalars replace, `null` removes a key. - **Environment:** `${VAR}` and `${VAR:-default}` in a value are filled at load; an unset variable with no default is an error naming the file and key. A model key comes from the environment with `key_env` (below), not through the file. - **Paths** in a file are relative to that file: the app, tokenizers, key files, `library`, `dir`, and local machines' homes. Paths given on the command line are relative to the current directory, so a deployment runs the same from anywhere. - **The journal directory** is `dir`, by default `.affordance/` beside the file. A run that finds an earlier run there stops and asks for `--resume` (continue it) or `--fresh` (start over; the earlier run's files move under `.retired/`). - **`affordance config`** prints the resolved deployment (same arguments as `run`), with the values of `secrets` hidden. ## Writing an app ```tsx import { ModelContext } from "@affordance/lib/model.tsx"; import { useLoop } from "@affordance/lib/loop.tsx"; import { useLog } from "@affordance/lib/log.tsx"; import { Tool } from "@affordance/lib/tool.tsx"; import { machineTools } from "@affordance/tools/machine.tsx"; export default function Main({ task }) { const log = useLog(); // what the model saw and said: app state useLoop(log); // ask the model, run its calls, repeat const [notes, setNotes] = useState("(none)"); // persisted by the runtime const {shell} = machineTools(harness.machine.get("home")); // the deployment binds this role /** Replace your notes. */ function set_notes({text}: {text: string}) { setNotes(text); return "saved"; } const app = <> {`${harness.agent} | notes: ${notes}\n`} {"[shell]"} {" [set_notes]\n"} { log.append({kind: "input", content: `[message from ${from}] ${text}`}); }}/> ; return {app}; } ``` Prefer imported tool functions through ``. `machineTools(machine)` in `views/tools/machine.tsx` creates `shell` from an explicitly selected machine capability; its output is `{result, exit, timed_out, seconds, ...}`. The same module exports `wait`, which takes optional `seconds` and event names (`events`), with at least one required. Apps own output truncation, process/file-watcher protocols and host instructions. A typed function imported from another file retains its defining module's docs and schema. Use `name`, `description` or `schema` overrides when exact names or integer constraints matter. The contract: | What | How | |---|---| | The screen | The app's render: the current turn of a template app (`TurnStrategy`'s final user turn). | | Tools | Functions: `` (`views/lib/tool.tsx`) takes the name from the function, the description from its JSDoc and the argument schema from its parameter's type. Underneath, `` elements with an `on*` handler (`tool_name`, `description`, `args_schema`). | | Tool results | The handler's return value: a string, any JSON (sent as JSON text), or `{result, ...meta}`. The meta keys stay on the log's `action` record for the app; templates show only `result`. A thrown error becomes `error: `. | | State | `useState` hooks, persisted and replayed by the runtime. Handlers change state only through setters. | | History | App state: `useLog()` (`views/lib/log.tsx`) keeps what the model saw and said (see below). The app chooses what to show by passing `log.records`, filtered and reduced, to `TurnStrategy`. | | The model | The app calls it: `model(view?, opts)` sends the page's own view (or an element) and resolves with the output; `dispatch(output)` runs its calls on the view the model saw. `log.step()` does both and writes the records. `views/lib/loop.tsx` has the common loops: `useLoop(log, {before?})` (ask, act, repeat: an autonomous agent) and `useConversation(log)` (answer whatever is unanswered: a chat). The runtime never calls the model on its own. | | Effects | In a task: `await harness.machine.call(args)`, `harness.store.*`, `harness.send`, `harness.wait`, `model`, `dispatch`, ... (below); `Promise.all([...])` runs several concurrently. `harness.now()`, `harness.random()` and `harness.stop(value)` are also available. `useEffect(setup, deps)` runs `setup` after a render whose deps changed (by `Object.is`, as in React). | | Events | `useEvent("message", listener)` (or ``, zero tokens). A listener runs when its event arrives, even while tools wait. | | Context window | Layout's: the binding's `context` (the model's context length, which a native binding's server reports when the runtime validates it; configured, it may only lower that; `"none"` opts out; logged per binding and in `observed.json` `models`) less `max_new` is every prompt's window; sheets cap boxes in it (`history { max-tokens: 75win; overflow: drop-start; drop-step: 25% }`, `result { ... clip-middle }`), and a prompt that does not fit is never sent (`ContextError`). An app that summarizes reacts to `useEvent("layout")`. `max_prompt` stays as a hard ceiling. | | Reading replies | The template adapter's `protocol` span: the syntax, the stop tokens, and whether the prompt ends inside thinking. Every stock template app declares it: GLM53App (`glm`), Qwen3App and Qwen3BaseApp (`hermes`), Qwen36App, MiMo25App and Nemotron3App (`xml`), Llama31App (`llama31`). Parsers exist for every rendered family (`docs/REPLY_PARSING.md`). | | Language | TypeScript/JSX with JavaScript semantics (`spec/LANGUAGE.md` §7), in apps and template adapters alike. | `harness` in a render: - `agent` (id); - `turn` (page model calls so far); - `clock_ms`; - `env` (the app-scoped environ); - `prompt_tokens` (length of the last prompt); - `machines` (the roles of the machines bound to this agent); - `model` (`{name, template, template_options}`, or null without a model); - `probe` (only inside a `measure` render). ## The log For a fleet with bursty messages, set deployment `"input_batch": 64` and use `harness.send(memberIds, payload)` for one durable multicast result. A hub that is a top-level spec can set `"dedicated_worker": true`; its descendants share the isolated worker. The shared `workers` pool remains available to other roots. Channels and membership stay app code. Batching is opt-in because listeners keep one frame's captures; use functional state updates when combining multiple events. Details and replay rules are in [HARNESS.md §3](../spec/HARNESS.md#3-the-loop). The runtime keeps no conversation. `useLog()` keeps one in app state, writing records from `model()`'s output and `dispatch()`'s results: | Record | Fields | When | |---|---|---| | `screen` | `ids` (the exact screen tokens), `images?`, hosted `content?` | The model replied to it (`output.screen`). | | `model` | `ids` (exact generated ids, stop token included), `reasoning`, `content`, `calls`, `problems`, `cut_off`, `provider_state?` | Each reply. | | `action` | `name`, `tool_call_id`, `result`, plus the app's meta keys, plus `error: true` | `dispatch` ran the call, or it was invalid (unknown tool or bad arguments, answered with an error result the model sees). In call order. | | `input` | `content` | `log.append(record)`, e.g. an incoming message. While a turn is open, appends are held and written after its results. | `useLog` assigns every committed record a stable `id` (`e0`, `e1`, …; named logs use `:e0`). These identify records; `ids` still means exact token IDs. The library exports `LogRecord` and the four record types. `recordText(record)` produces search/diagnostic text, including reasoning and calls; image parts appear as `[image]`. `log.get(id)` reads a full record, including one released to storage; `log.read(from,to)` reads a half-open range. Release preserves IDs on skeletons. Read a released record before formatting it. For summaries, render the ordinary screen through `ModelContext callable={false}` (or `TurnStrategy callable={false}`). Tool descriptions and screen tokens stay identical, while the projected tree has no model-callable handlers. Hosted bindings also disable provider tool invocation, overriding binding/per-call settings that require a tool; a violating response is an error. No stub-handler table or provider-specific summary option is needed. Records with `ids` re-render to exactly those ids (`docs/HARNESS_GUIDE.md` §5). Every turn whose history the app does not rewrite is therefore an exact append, and the runtime checks it: `metrics.exact_appends` vs `rewrites`, and `trace.jsonl`'s `append` field. Hosted records keep sent screen content and opaque provider continuation state, rather than duplicate JSON reply token IDs. Provider state must travel unchanged during ordinary turns; clear it explicitly when rewriting an assistant record. Provider prefix rules across folds are binding/app policy (see MODEL_BINDINGS.md). To reduce a record, the app maps it, e.g. `{...r, reasoning: "", provider_state: null, ids: null}`; dropping `ids` makes it render from its fields. That is how `views/agents/session.tsx` folds. Reply problems come from `reply.rs`: `no_action`, `unclosed_think`, `cut_off`, `bad_syntax`, `unknown_action`, `bad_args`, ... A reply with no tool call is allowed: the next screen follows, and the app can nudge. A reply cut off before `` is thinking, never content. A cut-off tool call is never run. ## Harness APIs | Call (in a handler) | Result | Served by | |---|---|---| | `fetch(input, init?)` | `Promise` | The bound `http` executor, with standard response readers. See [Fetch and MCP](HTTP_AND_MCP.md) for the supported Web API subset. | | `harness.machine.call(args)` | executor-specific JSON | The agent's first machine (below). Shell executors take `{cmd, timeout_s?, env?, stdin?}` and return `{output, exit, seconds, timed_out, truncated}`. `harness.machine.get(role).call(args)` picks a named executor. | | `model(opts?)` | an output: `{text, reasoning, calls, problems, cut_off, ids, screen, usage}` | the agent's model: the page's own view, laid out once, at the call. | | `model(, opts?)` | an output (no `screen`; not dispatchable) | the agent's model, through the harness: the component is laid out to exact ids and run once; its `protocol` span reads the reply. | | `dispatch(output)` | `[{name, id, result, error?, ...meta}]` | agent: runs a page output's calls on the view the model saw, concurrently; results in call order. | | `harness.send(to, payload, {event?})` | `{to, delivered}` | runtime: event `message` (or `event`) to agent `to`, with `payload.from` set by the runtime. | | `harness.wait({ms?, events?})` | `{woke: "timeout"}` or `{woke: "event", event, payload}` | agent: parks the handler (no model calls) until `ms` passes or an event in `events` arrives. | | `harness.store.put/get/search/list(args)` | `{key}`; `{text, total}`; `{hits}`; `{keys}` | runtime: per-agent storage (persisted with `dir`). | ## Machines A machine binding names an external executor: a shell, HTTP endpoint, or custom adapter. The deployment binds machines by role; an agent reaches only the machines bound to it (`harness.machines`), and the host checks every call against that binding. There is no other way out: running on the harness's own host means binding a `local` machine. | Kind | Config | Runs | |---|---|---| | `local` | — | `bash` on this host, with only the agent's environ. | | `ssh` | `target`, `key?`, `control_dir?`, `known_hosts?` | `bash` on a VM over a shared SSH connection. | | `docker` | `image`, `name?` (default `aff-{agent}`) | `bash` in a container per agent, started on first use and kept. | | `bwrap` | `home`, `network?` (default false) | `bash` in a bubblewrap sandbox: the host read-only, `home` writable, private /tmp. | | `command` | `argv` | An external executor: JSON requests on stdin, JSON responses on stdout. | | `http` | `url`, `headers?`, `timeout_ms?`, byte limits | HTTP requests on a bound origin. Grant the role `http` for global `fetch(input, init)`. See [Fetch and MCP](HTTP_AND_MCP.md). | `{n}` (the agent's index) and `{id}` in a machine's config make one machine per agent. An agent spec's `machines` lists the roles it is bound to; without it, every deployment machine. ## Folding (context policy in the app) `views/agents/session.tsx` keeps its fold state in a hook and renders history through `selectLog(log.records, fold)`. Its sheet (`session.tss`) caps the history at 75% of the window and tool output in tokens. Its fold policy reacts to the layout event: while a laid-out prompt passes `c_hi`, it takes one step per turn over the records before the recent window: 1. mask long tool outputs (level 1), then drop reasoning and old screens (level 2); 2. then summarize: `model()`, the agent's own context plus a maintenance request (same prefix, so it reuses the model server's cache), and release what it covers. Each step shows in the next prompt's layout; nothing measures candidate renders. After a summary the next fold waits for `cooldown` more tokens. The fold is state, so prompts are rewritten only when it changes, in bulk. Every other turn is an exact append. `tests/session_agent.rs` runs the consumer's 300-turn scenario on this app, with a scripted GLM model and a fake shell, at 48K/20K. The following baseline predates binding-selected templates; the current scenario continues to check exact appends, bounded folding, recall and replay: | | | |---|---| | invariant violations | 0 | | exact appends | 285 of 299 (the other 14: 12 folds and 2 branch returns) | | fold landings | 17.9–19.9K, plus 1 summarized at 7.7K | | max prompt | 47,972 | | prefix reuse | 94.5% | | recall from folded history | 3/3 questions | | `read_event` | byte-exact, 2/2 | | journal replay | identical state (the log included) | These match the consumer's own Rust harness: 13 folds, 284 exact appends, 94.1% reuse. The run takes 8.6 s for 300 turns on one thread, test scaffolding included; the mean turn render is 3.0 ms. ## The environ and secrets Each agent's `harness.env` has two layers: the deployment layer (`env` in the config), then the agent's own `env`. The runtime process's real environment is never visible. - A local `exec` child gets only `PATH`, `HOME`, `LANG` and the agent's environ. - An app may read its secrets and use them, the way a page reads its cookies. Showing a secret to the model is the app's choice. - Values of keys listed in `secrets` are redacted (`[secret:KEY]`) in `trace.jsonl` and `agent.json`, as values (a PEM key with newlines and quotes is found). On resume they come from the config again. A secret under 4 bytes is refused: it cannot be told from text. - `journal.jsonl` stores inputs verbatim (replay needs them). Treat it as sensitive. - The runtime's own credentials (SSH keys, endpoints, API keys) are machine and model configuration and never reach an app. ## Running many agents `affordance run`, or `Runtime` from Rust. The contract is `spec/HARNESS.md`; this section is how to run it. - **A root app or a list.** `{"root": {"app": "fleet.tsx", "props": {...}}}` runs a root app that mounts the rest (`views/demo/tree/fleet.tsx` mounts one agent per machine of a pool); `{"app": {...}, "agents": ...}` runs one app as a list of agents. - **Where apps import from.** An app's own directory (and below), and the library, which `@affordance/...` imports name: `library` in the deployment; else the library checkout the app lives in (the nearest directory above it holding `index.json`, or a checkout's `views/`); else the copy bundled with `affordance`. Any other import is a load error. - **One `Encoder` per model binding.** Render workers each own the views they load and a set of instances (children live with their parent). - **Pools.** Model and machine calls run on thread pools (`model_threads`, `exec_threads`). - **No polling.** An agent waiting on a slow command, a `wait` or a message costs no model calls. - **DP-rank pinning.** Model calls are pinned to a data-parallel rank (`rank` per agent, or a stable hash), so each agent's prefix stays in one rank's cache. - **Model servers** (`model`, and more by name in `models`): - `sglang`: `/generate` with `input_ids`, `stop_token_ids`, `no_stop_trim`, `routed_dp_rank`; - `completions`: `/v1/completions` with a token-id prompt and `return_token_ids`; - `chat`: an OpenAI-compatible `/chat/completions` (`url`, `model`, a key from `key_env` (an environment variable) or `key_file` + `key_name` (a file's `NAME=value` line), none for a local server, optional non-secret `headers`); the app renders through `MessagesApp`. With no `tokenizer`, a chat binding sizes its layout with the bundled default (GPT-2), since the server tokenizes the messages itself. Provider usage is preserved without cost interpretation. Prices, spending enforcement and account state belong to the host; legacy price/budget fields are rejected. See the model section in `spec/HARNESS.md`. A transient failure (network, cut stream, HTTP 429/5xx, cancellation) is retried by the agent with the same prompt, backing off to one try every 30 s, for as long as it lasts. Any other failure stops the agent after 5 in a row (`stopped.reason = "model_error"`). Streams stall after `stall_s` (default 5400 s). Model configurations are strict, including `provider_options` and per-binding bearer credentials. Native LoRA selection uses `provider_options.lora_path`; it is not sampling. See [the configuration contract](../spec/HARNESS.md#6-models) and [multi-binding examples, sessions and recovery](MODEL_BINDINGS.md). - **Files in the journal directory (`dir`):** - `//agent.json`: its spec; - `journal.jsonl`: every input, appended before it takes effect (durable at the next commit, below); - `trace.jsonl`: renders with prompt size, append check and render time; replies with problems; handler errors (a handler that threw, and a promise that rejected with nothing awaiting or catching it, such as an async function called and not awaited: `unhandled_rejection`); model errors; each machine call a tool makes (`call`: its task, tool and call id, so live output can be put under its tool call); - `store.jsonl`; - optional `checkpoint.json` (a verified recovery image; full journal retained); - `/blobs/`: large strings (below); - `/commit.jsonl`: the journals' commits (below); - `/runtime.lock`: locked by the one runtime that owns the directory. A second runtime over it (`--resume` or a fresh run) fails before it recovers anything; a crash releases it. - **Large strings live on disk.** With `dir`, a string of at least `spill_bytes` (default 64 KiB) is written once to `/blobs/` and travels through state, journal, store and commands as a short reference. The app sees an ordinary string: the language reads it as host text mapped from the file (`length`, `slice`, `indexOf`, ... read the mapped bytes without copying the whole string), so its pages are reclaimable file cache, not heap. `exec` writes an output past that size straight to a blob file as it arrives. Machine calls and reports get the text back; `send` and `store.*` pass references through. A 16 MiB tool output costs about a hundred bytes of heap wherever the app keeps it. Without `dir`, strings stay on the heap. - **Journals are committed in groups.** A worker writes an input's journal line when it applies the input and does not wait for the disk. A commit thread makes the lines durable together: it syncs every journal written since the last commit (in parallel) and then appends each one's length to `commit.jsonl`. It commits as soon as something waits on a commit or a worker has nothing queued, and otherwise at most 2 ms after the first line it covers. Whatever leaves the process waits for the commit of the input that caused it: model requests, machine calls, control receipts, and `observed.json`. A `harness.send` to another instance, timers, the instance tree and `store.*` operations do not wait. Store records and blob files are written without a sync too. A commit syncs them with the journals, before the record that commits any line referring to them, and the commit records each store's length beside its journal's. A model-free round trip between two instances costs a fraction of a millisecond instead of four disk syncs. This is the browser's IndexedDB with relaxed durability: a transaction completes before it reaches the disk, and the platform flushes in the background. Here, though, nothing outside the process sees a result before it is durable. - **A crash resumes from the last commit.** Resume first truncates every journal and store to the last committed length (bytes past it are kept in `uncommitted-journal-.bin` or `uncommitted-store-.bin`). A file the commit does not name for its instance's incarnation is emptied. A blob a committed line refers to was synced by that commit. A blob file a crash left without its content is rewritten when its text is next stored: a put checks an existing file's content once per process before reusing it. A line is written only after every line that caused it, in any journal, so a commit never holds a delivery without the send that caused it, nor a sender's acknowledgement without the delivery. The run resumes in a state it passed through. The work after the last commit (a few milliseconds of in-process work) is computed again, and nothing outside the process saw it. A recovery image taken after the last commit is ignored, and that agent replays its journal in full. A clean shutdown commits everything and closes the log, so the next resume truncates nothing. Only the log's last line may be torn: a bad line before it is damage, not an older commit, and recovery refuses it (naming its byte offset) rather than cut every journal back. Each truncation is printed with the instance id and byte counts. - **Resume.** `--resume` rebuilds every agent from its journal (same renders, same state) and resubmits its outstanding work. External work in flight at the crash is submitted again, so machine commands with durable side effects should deduplicate by agent and task id. ```yaml app: view: views/agents/session.tsx tokenizer: tokenizers/GLM-5.3.json model: kind: sglang url: http://10.0.0.1:30000 template: glm53 template_options: {reasoning_effort: high} ranks: 8 sampling: {temperature: 0.6} machines: home: {kind: ssh, target: "agent@10.0.1.{n}", key: keys/agent_ed25519, timeout_s: 60} agents: count: 512 id: "agent-{n:04}" max_new: 4096 max_turns: 300 props: {id: "{id}", n_agents: 512, task: "...", c_hi: 48000, c_lo: 20000} env: {SUB_MODEL_KEY: "${SUB_MODEL_KEY}"} secrets: [SUB_MODEL_KEY] workers: 8 model_threads: 1024 exec_threads: 1024 max_wall_s: 86400 ``` `{n}` and `{id}` are filled per agent in agent fields and machine configs: one VM per agent. `ranks` is the model server's data-parallel size: each agent is pinned to one rank, so its prefix stays in one engine's cache (`sglang`: `routed_dp_rank`; `completions`, vLLM: the `X-data-parallel-rank` header). Without it, a data-parallel server spreads an agent's turns over its engines and most of each prompt is prefilled again. The Rust API: `Runtime::new(config, Arc, Arc)` (the `default` model), then `.bind_model(name, enc, model)` and `.bind_machine(role, machine)`, then `.add_agent(spec)` or `Runtime::resume`, then `.run() -> Report`. `runtime::Agent` is the single-agent core with no I/O (`start`, `handle(input) -> commands`, `restore(journal)`), for custom hosts and tests. Its one exception: with `set_blobs`, it writes large strings as content-addressed blob files (idempotent, so replay is unchanged). ## Serving over HTTP `affordance serve [PATH]` runs a deployment behind HTTP, as a Service Worker answers `fetch` events. It takes `run`'s options and `--listen HOST:PORT` (or `serve: {listen, max_body_bytes, timeout_s}` in the deployment), and keeps running. - Each request reaches the first instance (the root, or the one agent) as the event `request`: `{id, method, path, query, headers, body}` (headers lowercase; the body as text). It is a journaled input, so a served app replays exactly. - The app answers on the machine `client`: `{respond: id, status, headers, body}` (a body that is not a string is sent as JSON), or `{respond: id, status, headers, stream: true}`, then `{respond: id, chunk}`..., then `{respond: id, end: true}`. An answer to a request that is gone (answered, disconnected, or from before a restart) fails as a call error. - A request not answered within `timeout_s` (default 600) gets a 504. HTTP/1.1, one request per connection, bodies with Content-Length; no TLS (put a proxy in front, which also owns auth). - A model's reply can be **piped** into a streamed response as it is generated: `model(view, {pipe: {machine, call, text?, reasoning?, tool_call?}})`. Each piece of the reply is written to the machine as `{...call, chunk}`, where the chunk is the kind's frame with the piece substituted (escaped as the inside of a JSON string) for the placeholder; a kind with no frame is not written. The platform writes the chunks: no app code runs for a piece, nothing of a piece is journaled, and the call resolves with the same whole reply as without a pipe. On a replay nothing is written. A sink that fails (the client went away) closes the pipe; the model call goes on unless its `signal` stops it. Pieces come from a hosted binding's stream; a native binding's reply is not piped yet. `views/lib/http_server.tsx` gives an app `useRequests(async (req, res) => ...)` with `res.json`, `res.text` and `res.stream` (whose `out.pipe(frames)` makes the `pipe` option, with `PIECE` as the placeholder); `views/lib/openai_server.tsx` builds an OpenAI-compatible `/v1/chat/completions` (streamed or not; a streamed request hands the app a `pipe` for its model call, so the reply streams as it is generated) and `/v1/models` on it. `views/demo/memory_server` is a chat model with a memory longer than its context behind that endpoint, and `views/demo/serve` the smallest served app. An embedding program posts its own events into a running runtime with `Runtime::inbox()`. ## Driving an app from a frontend `affordance host [PATH]` runs a deployment for a frontend (a terminal UI, an editor) over stdio, one JSON object a line, as a browser's DevTools protocol drives a page. It takes `run`'s options and keeps running; stdout carries only the protocol (the summary goes to stderr). - **In** (stdin): `{"type": "prompt", "text"}` posts the event `user` (`{text}`) to the first instance; `{"type": "abort"}` posts the event `abort`; `{"type": "history"}` posts the event `history`; `{"type": "event", "name", "payload"}` posts any event; `{"type": "answer", "request", "value"}` answers a question; `{"type": "shutdown"}`, or the end of stdin, ends the run (`--resume` continues it). - **Out** (stdout): `ready {agent}`; `delta {agent, task, text, reasoning?}` (or `delta {agent, task, call: {index, id?, name?, arguments}}`, more of a hosted reply's tool call: its place in the reply, its id and name when they arrive, and more of its arguments' JSON text), a model's reply as it is generated; `output {agent, task, text}`, a command's output as it is written; `trace {agent, record}`, the trace records (renders, replies, each model call's `generate` statistics such as `first_token_ms` and `tokens_per_s`, finished tools, handler errors, the app's `console` lines); `ui {agent, value}` and `question {agent, request, ...}`; `error {message}`; `exit {error?}`. - **The machine `ui` is the frontend.** A call with `ask` (`{ask: {kind: "permission", ...}}`) becomes a `question`, and waits for the frontend's `answer`, whose value it returns; any other call is shown as `ui` and returns null at once. What to ask, and when, is the app's. The deltas and output are what the observer sees (`RuntimeConfig.observer`); they are never journaled. Prompts, aborts and answers are: a prompt and an abort are events, an answer is the `ui` call's result. `views/lib/frontend.tsx` is the chat loop: `useFrontend(log)` answers each `user` message, runs the model's calls until it replies without one, and aborts the turn in progress on `abort`. It tells the frontend what it is doing as `ui` values: `{state: "working" | "idle"}`, `{calls: [{id, name, args}]}` as the model's tool calls start, `{results: [...]}` when they end, and, in answer to the event `history`, `{history: [...]}`: the conversation so far, for a frontend that connects to a resumed run. These are the app's own messages, not the protocol's: another app tells its frontend whatever it likes. Through `turn.signal`, a tool's command stops with it. `ask(question)` asks the frontend. `views/tools/files.tsx` has `read`, `write` and `edit` tools over a machine's shell. `views/demo/host` is a coding chat that uses them and asks before each call: ```sh cd views/demo/host && affordance host --set model.url=http://127.0.0.1:8080/v1 {"type":"prompt","text":"What is in this directory?"} ``` An embedding program does the same with `Runtime::inbox()` (`post`, and `close` to end the run), `Runtime::observe`, and a machine of its own. **As another harness's model layer.** A harness that keeps its own loop, tools and sessions can use Affordance for the model call alone. `views/lib/model_layer.tsx` (`useModelLayer()`) takes each call as the event `generate` (`{id, system, messages, tools, max_new}`: what a chat API takes), renders the conversation through the bound model's template, calls the model and answers on `ui` with `{generated: {id, output: {text, reasoning, calls, cut_off, aborted, usage}}}`. The reply streams as `delta` events, and the event `abort` (`{id}`) stops a call. The tools are declared for the model and never run here: the caller runs them. Nothing of the app's own is added to the conversation. ## Staying up and draining `affordance run --keep-running` (or `keep_running: true`; `RuntimeConfig.serve` in Rust) keeps the run up while instances are idle, a chat waiting for its user for example, and keeps reading the control inbox. The run then ends when every instance stops, at `max_wall`, or on a pause. Without it (the default, for batches) the run returns once nothing is in flight and no instance waits on anything. `observed.json` reports an instance with nothing outstanding as `idle`. A control-inbox file may name its event (`"event": "ping"`, default `message`), and so may a send: `harness.send(to, payload, {event: "ping"})`. Workers give local completions, due timers and incoming/control messages a turn between atomic inputs. During draining, newly requested model and host calls remain outstanding instead of starting more work. A resumed run reissues them from the journal. Calls already running retain the selected pause mode's completion/cancellation behavior. A model or machine call that the host cancels (an immediate or fast pause, the run ending) is not recorded as a failure: nothing is journaled for it, it stays outstanding, and a resumed run issues it again. A call the app cancels, or one whose instance is unmounted, is recorded as before. Errors never name a model server's address: transport failures say the binding and endpoint (`sglang /generate: the server closed the connection without a response`), and a hosted binding's error has its host replaced by "the model server". The address is in the binding's configuration, not in journals, traces, captures or what an app sees. An instance's failure is its own, as a crashed tab's is. An error or a panic in its own code (loading its app, starting, replaying its journal, handling an input) fails that instance alone: it is reported with the error, never run or imaged again, and its worker serves the others. A resume replays the same journal and fails it the same way. A render worker that dies outside its instances' code ends the run at once: its instances are reported failed, the others paused for a resume. A drain does not wait behind queued inputs that will be issued again on resume. A queued `harness.send` delivery is not applied: the receiver does not journal it and the sender gets no answer (not `delivered: false`), so the send stays outstanding in the sender's journal and the resumed run delivers it. A queued control-inbox message is not applied either: with no receipt, it is posted again on resume. Everything else still queued (model and machine completions, timers, mounts, props, callbacks) is applied as before. A multicast send whose delivery reached some targets before the pause stays pending and is resent to all of them on resume; a target that already journaled it journals the copy but does not take it twice (its `_msg` id), the same at-least-once delivery as after a crash. This is a page being unloaded: its queued messages are not delivered, and the sender's request stays pending and is retried. ## Cancellation and streaming An app stops its own calls with `AbortController`, as a web page stops a `fetch`: ```tsx const [turn, setTurn] = useRef(null, "turn"); useEvent("user", async ({text}) => { const controller = new AbortController(); setTurn(controller); const output = await model({signal: controller.signal}); // Escape: output.aborted, and output.text is what was generated before it. }); useEvent("abort", () => turn()?.abort()); ``` - A model call stops generating and resolves with what it has (`aborted: true`, `cut_off: true`); the partial reply is journaled, so the log keeps it and a replay matches. A reply that finished first is kept whole. - A machine call or `fetch` is cancelled as below and rejects with `AbortError`. - A call given a signal already aborted is not issued. The rules are in `spec/HARNESS.md` §3. Under them, every model and machine call carries a host cancel token (`runtime::CancelToken`): - **When work is cancelled.** - An agent stops: it cancels everything it asked for. - A handler fails: the calls it started and did not await are cancelled. (A handler that returns normally keeps its un-awaited work going until it settles.) - The runtime shuts down (end of run, or `max_wall`). - A machine call passes `call_timeout`. - **How it stops.** - A local `exec` sends SIGTERM to the command's whole process group (led by `timeout`), then SIGKILL after 5 s. - Cancellation drops an active HTTP request; remote effects already performed are not rolled back. - Over SSH, cancelling kills the local `ssh` client; the remote command runs on until its remote `timeout` fires. - **Late results** of cancelled work are ignored (deterministically: the journal replays the same). Model replies stream by default (SGLang `/generate` with `stream`, or `/v1/completions` with `stream` + `return_token_ids`; set `stream: false` in the model binding to turn it off): - **Abort.** A cancelled request closes its stream, which stops generation on the server. A blocking request would keep generating to the end. - **Stalls.** If no token arrives for `stall` (default 300 s, `"stall_s"`), the request is aborted and retried. - **Cut streams.** A stream that ends without a finish reason (server or network died) is an error and is retried, never mistaken for a complete reply. So is a request the server finished with `abort` (stream or not): what it generated is not a reply. - **Live view.** `RuntimeConfig.observer` sees what each call produces as it arrives (`runtime::Observed`): a native model's new ids, a hosted model's new text and reasoning, and a machine call's output as the command writes it. It is never journaled, so watching changes nothing. Hosted calls stream (server-sent events) when something watches or can stop them (an observer, or a call with an `AbortSignal`); the streamed events are rebuilt into the response the provider sends unstreamed, so the reply ids are the same either way. - **Trace.** Each model call gets a `generate` entry: prompt and generated tokens, ms, time to first token, tokens/s, cached tokens, rank. ## Limits - **Not built yet:** - a background-process table (`proc.*`); - vsock `exec`; - dispatching a tool call before its reply has finished streaming; - per-agent send rate limits (policy belongs in the app; a hard cap in the runtime is not built). - **One default root app per deployment.** Child mounts can select imported components from other files, with their own state, journals and bindings. - **The log's turn is serial; model calls are not.** Any number of `model()` calls may be open at once. A log (`useLog`) holds one turn open at a time: records appended meanwhile wait in `log.held` and are written after the turn's results (so a prompt plus its reply stays a prefix of the next prompt). - **Hook writes: the last writer wins**, in journal order. A setter given a function (`setNotes(ns => [...ns, x])`) applies it to the current value; a setter given a value computed from a render's copy (`setNotes([...notes, x])`) can overwrite a write made since that render. - **Exact model turns need a supporting template app.** Every stock template app except MessagesApp (text for hosted APIs) supports them. Tokenizer availability is a development-reference concern, not a different runtime contract. ## Choosing context limits The session app's `c_hi`, `c_lo`, `min_freed`, `keep_recent` and `cooldown` choose when and how it folds history; `max_new` bounds generation. Choose them against the bound model's context limit and the app's measured workloads. Rendering conformance does not establish task quality or an optimal folding policy. Training and comparative task studies belong to the separate research project. --- Source: docs/MODEL_BINDINGS.md Page: https://affordance.rs/docs/model-bindings.html # Configuring model bindings The configuration contract is in [HARNESS.md §6](../spec/HARNESS.md#6-models). A binding combines a tokenizer and one stock client. `model` names the default binding; `models` adds names selected by `agents[].model` or ``. Every client has its own endpoint, options, credentials, defaults and session table. Identical tokenizer paths share an immutable encoder in the CLI. ## Template selection A native binding can name `"template":"glm53"` and `"template_options":{"reasoning_effort":"high"}` (each app's options are declared in the registry; another is refused). Use keys from `views/template_apps/index.json`; no model-name heuristics select a family. Hosted chat, Anthropic and Responses default to and require `messages`. Options must be an object and cannot replace app-owned `system` or `children`; other props follow the selected template's semantics. Use the matching tokenizer: an absent required special token is a render error before a request is sent. An app can then omit model names: ```tsx import { ModelContext } from "@affordance/lib/model.tsx"; import { useLog } from "@affordance/lib/log.tsx"; import { useLoop } from "@affordance/lib/loop.tsx"; export default function Main() { const log = useLog(); useLoop(log); return Ready. ; } ``` `harness.model` exposes only the binding's name, template key and template options. It is null for a coordinator without a model. Explicitly imported template apps remain useful for standalone renderers and custom compositions. Native bindings without a configured template can still use them; `ModelContext` requires explicit configuration. Presentation is pinned with the binding; strict recovery rejects changed template/options. Template source changes use the existing app source pin. Supply host instructions through app props, for example the bundled session's `instructions`, rather than shared-code paths. ## The window Every prompt's window is the binding's `context` (the model's context length) less the call's `max_new`, as a page's viewport is the screen it is shown on. The model server knows it: a native binding (SGLang, completions) asks once, when the runtime validates it, `GET /v1/models` (the entry named `name`; for SGLang, which names no model, the only entry; its `max_model_len` or `context_length`), then SGLang's `GET /get_server_info` (`context_length`). A server still starting is asked again twice. So a native binding needs no `context`: - `"context": 65536` only lowers the window: more than the server reports fails validation, with both numbers. A server that cannot be asked leaves the configured value (one warning line). - `"context": "none"` runs without a window, by choice: no request, no layout cap, no pre-send check. - With neither, a server that reports no context length fails validation, naming the binding and each endpoint tried. A `chat` binding with no `context` asks its server the same way (`GET {url}/models`): a self-hosted OpenAI-compatible server (vLLM, SGLang) reports its window there. Hosted APIs (and the Anthropic and Responses bindings) do not report one: such a binding needs `context` (or `"none"`), and the error says so. The runtime logs each binding's window on stderr (`model binding "default": window 262144 tokens (server /v1/models)`), and `observed.json` shows it under `models` (`{"default": {"context": 262144, "source": "server /v1/models"}}`). ## Provider options and sampling `provider_options` is copied directly to the top level of the outgoing provider JSON object. For native SGLang, use `{"provider_options":{"lora_path":"variant-b"}}`. The adapter must already be available on that server; this configuration does not load model weights. SGLang's compatible OpenAI APIs instead select LoRAs through their model selector, for example completions `"name":"base-model:adapter-name"`. See [SGLang's serving contract](https://docs.sglang.io/docs/advanced_features/lora). Binding `sampling` supplies defaults. The model call's `sampling` overrides those defaults one field at a time. SGLang puts these in `sampling_params`; completions and chat put them at the top level. Nested objects are replaced, not recursively merged. Provider options are fixed for a binding. A sampling key cannot also be a provider option; model/adapter identity, authentication and host-controlled request fields cannot be sampling keys. Select another configured binding to use another model or LoRA. Do not put an endpoint, credential, prompt, stop rule, output limit, stream control, session or request ID in provider options. Such conflicts fail before sending a request. The framework rejects unknown configuration keys, invalid types and protected fields. It forwards provider extensions unchanged; the server validates extension names and semantics. There is no universal schema for every provider version. A misspelling inside an opaque provider extension can only be rejected if that server rejects it. Use wire-fixture tests when adopting a provider extension whose acceptance matters. SGLang `lora_path` is locally checked as a nonempty string or null because each request contains one prompt. ## Credentials and transport `key_file` and `key_name` select a binding-local bearer credential. Both are required together; native adapters can omit both, while chat requires them. The file contains `NAME=value` lines, optionally prefixed with `export`; single/double quotes around a value are supported, without shell expansion. A key must be nonempty printable ASCII without whitespace. Values are read at request time so the file can be rotated in place. A hosted binding sends the key in its API's own header (`Authorization: Bearer`, Anthropic's `x-api-key`) unless `key_header` names another credential header (`api-key` for Azure OpenAI, `x-goog-api-key`); the non-secret `headers` cannot carry one. Credentials go only in Authorization headers, including SGLang session and abort requests. Captures redact that header; provider error messages and diagnostic metadata also redact echoed credential values. They are absent from argv, model-visible options and persisted binding identity. Provider options are ordinary request data and may be captured; use the credential fields for secrets. Chat bindings additionally accept `"headers": {"HTTP-Referer":"https://example.test/", "X-Title":"My app"}`. These are fixed non-secret strings. Names and values are validated; duplicate names ignoring case, Authorization and Content-Type are rejected. Headers travel through curl's stdin configuration, and are visible in request captures. Keep secrets in the file-loaded bearer credential. Environment credentials are deliberately unsupported because tools inherit the process environment. Changing headers changes binding identity. Chat requests and responses use the shared atomic capture writer when `AFFORDANCE_CAPTURE_DIR` is set. Provider usage is preserved under `model_meta.usage`, including provider cost fields, without price conversion or enforcement. Local input/output counts and provider cached-token counts remain in per-request logs. Missing usage does not invalidate an otherwise valid reply. There is no cumulative budget; `max_new` still bounds a single request's output. Money and account limits belong to the embedding host. Remove `price_in`, `price_out` and `budget_usd` from older configurations; strict validation rejects them. Native SGLang/completions retain the stock HTTP/1.1 transport, including SSE and cancellation. Their URLs require `http://` and a hostname or IPv4 address. Chat retains its HTTP(S) curl transport and a single non-streaming choice; IPv6 literal hosts are also supported there. `request_timeout_s` bounds the whole request, from connecting to the reply's last byte, for native non-streaming calls and chat alike; native SSE uses `stall_s` per event instead. Fractional seconds are supported; chat timeouts round up to curl’s millisecond resolution so a tiny positive timeout never disables the limit. The native transport bounds what it reads: a 64 MiB body (or SSE event), a 16 KiB status or header line, 100 header fields, and the first 8 KiB of a failure's body (which only goes into the error). A reply over a limit is a protocol error, not retried. Curl runs with `-q` (no `~/.curlrc`) and only HTTP(S); it keeps the environment, so a deployment's proxy and CA bundle variables apply. Base URLs cannot contain credentials, queries or fragments. Paths such as `/api/v1` are valid. Do not include the final `/generate`, `/v1/completions` or `/chat/completions` suffix. ## Describing tools without allowing calls `ModelContext callable={false}` (or `TurnStrategy callable={false}`) retains tool definitions and removes callable handler references. `MessagesApp` carries that restriction to hosted clients. After merging binding/provider/per-call settings, chat and Responses send `tool_choice:"none"`; Anthropic sends `tool_choice:{"type":"none"}`. A forced-tool default cannot override a passive render. Without definitions, the tool-choice field is omitted; legacy function options are removed from passive requests. Active requests keep their usual settings and framing. If the provider nevertheless returns a tool call, including a truncated call, the adapter returns a non-transient protocol error rather than silently producing an empty summary or accepting an invocation. Ordinary prose quoting a tool is still text. Disabling tool calls does not guarantee nonempty prose: applications that require a summary must validate its text. Tool and system definitions remain identical. Anthropic documents that changing tool_choice invalidates the messages-cache tier while retaining tools/system caches; identical descriptions do not guarantee a full history cache hit. See the provider [caching rules](https://platform.claude.com/docs/en/build-with-claude/prompt-caching). ## Anthropic Messages `"kind":"anthropic"` is a hosted binding for `POST {url}/v1/messages`. Like chat, it renders through and requires the `messages` template, reads `key_file`/`key_name` at request time, does not stream and writes the reply back as `{reasoning, content, tool_calls}`. | Field | Contract/default | |---|---| | `model` | Required provider model id, e.g. `claude-opus-5-5`. | | `url` | Optional; default `https://api.anthropic.com`. HTTP(S), without the `/v1/messages` suffix. | | `key_file`, `key_name` | Required. The key is sent as `x-api-key` and redacted like a bearer key. | | `headers` | Optional fixed non-secret headers, e.g. `anthropic-beta`. `x-api-key`, `anthropic-version`, `Authorization` and `Content-Type` are client-owned; the client sends `anthropic-version: 2023-06-01`. | | `cache_ttl` | `"5m"` (default) or `"1h"`: the TTL of the binding's cache breakpoints. | | `retries` | Nonnegative count, default 3: resends after transient transport errors, HTTP 429, 529 or another 5xx, waiting `Retry-After` (seconds or HTTP-date) or 1, 2, 4… s (fallback capped at 64 s; provider waits are never shortened). | | `sampling`, `provider_options`, `request_timeout_s`, `template`, `stream` | As for chat. `thinking`, `output_config`, `tool_choice`, `metadata` and other request fields go in `provider_options`. | The client maps `MessagesApp`'s turns: the leading system turn becomes the top-level `system` (a later system turn is user text); tools become `{name, description, input_schema}`; an assistant turn uses its original native content blocks when available, including signed/redacted thinking and tool-use IDs; without native state it becomes `text` and `tool_use` blocks; tool results become `tool_result` blocks in the following user message, paired with their calls by `tool_call_id` (or position), with image parts as image blocks (data URLs as base64 sources, other URLs as URL sources). Empty text is omitted and consecutive same-role messages are merged, as the API requires. `max_new` is `max_tokens`; `stop_reason: "max_tokens"` sets the reply's cut-off flag. The binding places up to three explicit `cache_control` breakpoints: the last tool, the system prompt and the newest cacheable history block before the live screen. Thinking blocks are never marked directly. Cache reads depend on the provider's minimum prefix length and actual prefix equality. `useLog` retains each hosted screen's sent text/image content; it no longer becomes blank on the next turn. A fold deliberately starts a different prefix. Usage is preserved under `model_meta.usage`, including `input_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` and `output_tokens`. Additive aliases are `prompt_tokens` (all three input counts), `prompt_tokens_details.cached_tokens` (reads), `prompt_tokens_details.cache_write_tokens` (creation) and `completion_tokens`. When reported, thinking-token details also receive a `completion_tokens_details.reasoning_tokens` alias. No prices are calculated. `cached_tokens` in request logs reports the cache read. Thinking text is visible as `reasoning`; the original content blocks are also retained in opaque `provider_state`. `useLog` and `ModelContext` carry this field unchanged into later requests, including signatures, redacted blocks and original tool IDs. A lossy tokenizer is rejected rather than corrupting the signed text. Adaptive thinking may skip thinking on any turn; it does not require a thinking block on every response. Legacy manual thinking requires native continuation state for the last assistant tool turn. Rewriting history can invalidate a provider-bound thinking prefix. The app must explicitly clear `provider_state` when discarding or rewriting an assistant record; it must not send old opaque text alongside a different visible message. The example session clears it when dropping old reasoning. For models/accounts that require a prefix policy across folds, fixed binding options can explicitly request dropping mismatched thinking blocks, as below. Affordance does not silently strip valid thinking during ordinary turns. ```json "models": { "claude": { "kind": "anthropic", "model": "claude-opus-5-5", "tokenizer": "ref/tokenizers/zai-org__GLM-5.3.json", "key_file": "/run/secrets/anthropic.env", "key_name": "ANTHROPIC_API_KEY", "headers": {"anthropic-beta": "thinking-binding-controls-2026-08-01"}, "provider_options": { "thinking": {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "drop_block"}}, "output_config": {"effort": "high"} }, "cache_ttl": "5m", "request_timeout_s": 900 } } ``` Models with budgeted thinking (for example Haiku 4.5) take `"thinking": {"type": "enabled", "budget_tokens": 2048}` instead, below `max_new`. `tests/hosted_client.rs` exercises the real TSX app/log/dispatch path, including checkpoint and full replay. The small live check uses Haiku 5.5 with adaptive thinking: ```bash AFFORDANCE_ANTHROPIC_SMOKE=1 cargo test --release --test hosted_client live_native_sessions -- --nocapture ``` `AFFORDANCE_ANTHROPIC_KEY_FILE` names a file with an `ANTHROPIC_API_KEY=` line. It requires cache reads from turn two and exercises native reasoning. ## OpenAI Responses `"kind":"responses"` calls `POST {url}/responses` directly. The default base URL is `https://api.openai.com/v1`. It uses the same file-only bearer credentials, validated non-secret headers, captures, cancellation and synchronous transport as chat. Its template is `messages`. Thinking/reasoning wire settings belong in provider_options or per-call sampling, rather than MessagesApp template props; presentation and wire configuration remain separate. The provider API owns model-specific option validation; unsupported sampling settings produce an explicit provider error rather than being silently removed. ```json "models": { "gpt": { "kind": "responses", "model": "gpt-6.1-sol", "tokenizer": "ref/tokenizers/zai-org__GLM-5.3.json", "key_file": "/run/secrets/openai.env", "key_name": "OPENAI_API_KEY", "provider_options": { "reasoning": {"effort": "high", "context": "all_turns"}, "text": {"verbosity": "low"}, "prompt_cache_options": {"ttl": "30m"} }, "strict_tools": false, "retries": 3, "request_timeout_s": 600 } } ``` System messages become developer input; user content becomes `input_text`/`input_image`. Assistant native output items are replayed intact, including encrypted reasoning and function calls. Function results cite the provider's **call_id**, not the function-call item ID. Without native state, visible assistant text/calls are converted to message/function-call items. Tool text and images travel together inside function_call_output content, including parallel results. Function tools retain their schemas; `strict_tools` is a boolean, default false. Setting it true asks the provider for strict schema validation; the binding does not rewrite optional fields or expand the schema. The runtime supplies `max_output_tokens` from `max_new`, `store:false` and encrypted-reasoning inclusion. These transport-owned fields cannot be overridden by provider options. A response with `status:incomplete` and reason `max_output_tokens` sets `cut_off`; partial calls are not dispatched or replayed as unanswered native calls. Valid reasoning state remains available. HTTP-200 error objects, failed responses and other incomplete states are errors. `provider_options` accepts `reasoning`, `text`, `parallel_tool_calls`, `service_tier`, cache options and other provider extensions. A stable default `prompt_cache_key` hashes binding identity plus agent ID. It contains no credential path in clear text. An explicit nonempty key of at most 64 bytes may be supplied in provider options. Keep it stable across turns. Current models use `prompt_cache_options.ttl`; see the official [caching contract](https://developers.openai.com/api/docs/guides/prompt-caching). Usage retains the provider's fields and adds `prompt_tokens`, `completion_tokens`, `prompt_tokens_details` and `completion_tokens_details` aliases for input/output usage and cache/reasoning details. Provider usage is authoritative; local tokenizer counts measure the transport document, including opaque state, and are not provider billing counts. Both native hosted adapters retry transient transport failures, 429 and 5xx, at most `retries` resends (default 3). Valid numeric/HTTP-date `Retry-After` is a minimum and is never shortened. On 429, OpenAI reset-duration headers and Anthropic reset-date headers are also observed; the longest valid minimum is used. Waits are cancellable; `request_timeout_s` applies separately to each HTTP attempt, not the provider-requested wait. Exhausted transient failures retain the runtime's existing retry policy. Binding identity pins model, request options, headers, credential source and native retry/timeout settings during strict recovery. ```bash AFFORDANCE_RESPONSES_SMOKE=1 cargo test --release --test hosted_client live_native_sessions -- --nocapture ``` This runs three GPT-6.1-sol app turns with reasoning/tools, checks cache reads from turn two, and verifies checkpoint/full replay makes no new calls. `AFFORDANCE_RESPONSES_KEY_FILE` names a file with an `OPENAI_API_KEY=` line. Provider reasoning rules are documented by [OpenAI](https://developers.openai.com/api/docs/guides/reasoning) and [Anthropic](https://platform.claude.com/docs/en/build-with-claude/thinking-tool-workflows). ## Sessions and recovery A SGLang session belongs to one client identity. A changed endpoint, provider options, credential source or binding defaults requires a fresh client or `Sessions` value. Per-call sampling can vary. Two clients using the same server and agent ID still have separate sessions. Server session loss retains the existing fallback to a full prompt; sockets and server sessions are never serialized into a journal. Persisted instances record a digest of their built-in model binding's replay identity alongside the existing app/tokenizer pins: which model answers (kind, model, provider options such as a LoRA, template, context: the window in effect, the same whether the server reported it or the config stated it), not how it is reached (address, credentials, ranks, timeouts, retries, cache TTL) or sampled, so moving a server or changing a timeout resumes. `"replay":"strict"` refuses a changed or missing model identity before inference. Warning replay reports the difference and preserves the journal's original pins. Existing journals without this identity cannot acquire a strict guarantee retrospectively. Keep the original model for strict recovery, or explicitly use warning replay when accepting a changed/unverifiable binding. Weights swapped under the same model name are outside the guarantee. Recorded replies replay without network I/O. Outstanding work may run again using the pinned binding, following the existing at-least-once recovery contract. Provider-side replacement of weights under the same model/adapter name is outside that guarantee: deploy versioned names when model identity must be reproducible. ## Small deployment example Create `bindings-app.tsx` at the repository root: ```tsx import {ModelContext} from "./views/lib/model.tsx"; export default function Main({task}) { useEffect(() => { (async () => { const out = await model(); harness.stop({reason: "complete", answer: out.text}); })(); }, []); return {task}; } ``` Here A uses the existing `default` name. A/B share a server with separate LoRAs and credentials; C uses a compatible completions endpoint. All three servers in this example use the GLM vocabulary/template; select matching binding templates and tokenizer files for other families. The endpoint addresses and credential paths are supplied by the deployment operator. ```json { "app": {"view": "bindings-app.tsx", "tokenizer": "ref/tokenizers/zai-org__GLM-5.3.json"}, "model": { "kind": "sglang", "url": "http://127.0.0.1:30000", "template": "glm53", "template_options": {"reasoning_effort": "high"}, "key_file": "/run/secrets/model-a.env", "key_name": "API_KEY", "sampling": {"temperature": 0.2}, "provider_options": {"lora_path": "variant-a"} }, "models": { "B": { "kind": "sglang", "url": "http://127.0.0.1:30000", "template": "glm53", "template_options": {"reasoning_effort": "high"}, "tokenizer": "ref/tokenizers/zai-org__GLM-5.3.json", "key_file": "/run/secrets/model-b.env", "key_name": "API_KEY", "sampling": {"temperature": 0.7}, "provider_options": {"lora_path": "variant-b"} }, "C": { "kind": "completions", "url": "http://127.0.0.1:8000", "template": "glm53", "template_options": {"reasoning_effort": "high"}, "name": "glm-base:variant-c", "tokenizer": "ref/tokenizers/zai-org__GLM-5.3.json", "key_file": "/run/secrets/model-c.env", "key_name": "API_KEY", "sampling": {"temperature": 0.1}, "provider_options": {"logprobs": 1} } }, "agents": [ {"id": "one", "model": "default", "props": {"task": "Say hello."}}, {"id": "two", "model": "B", "props": {"task": "Say hello."}}, {"id": "three", "model": "C", "props": {"task": "Say hello."}} ], "dir": "runs/bindings", "replay": "strict" } ``` Save it as `affordance.yaml` (JSON is valid YAML) and run `affordance run`; add `--resume` to recover that run. This example adds no machine bindings or tool execution. ## Rust embedding `runtime::model::from_config(&json, encoder)` builds and validates the same stock adapters used by the CLI, returning `Arc`. Bind it with `Runtime::bind_model(name, encoder, client)`. The caller supplies the encoder; `tokenizer` in deployment JSON is loaded by the CLI. Direct constructors remain available: `Sglang::new`, `Completions::new`, `Chat::new`, `Chat::anthropic` and `Chat::responses` (whose `api` fields hold protocol settings). `model::with_template(client, ModelTemplate::new("glm53", options)?)` attaches presentation to any client while delegating its inference, streaming, cancellation and rank behavior. `Chat::new` advertises `messages` by default. Low-level `Agent` callers set `AgentConfig.template` to the same resolved `ModelTemplate` on fresh execution and restore; `Runtime` does this automatically for each binding, including mounted children. Wrapping a custom client preserves its identity opt-in: template metadata does not establish the identity of an otherwise unidentified inference implementation. Such clients must supply `Model::binding_identity` to receive strict binding-configuration checks. Checkpoint cache identity still includes the resolved `AgentConfig.template`. Native clients expose `auth: Option` and all stock clients expose `provider_options`. `Model::validate` runs before runtime workers start and before stock generation; mutating public client settings does not bypass validation. Custom adapters can implement `binding_identity` with non-secret, versioned configuration. Its default is absent: the runtime cannot certify undeclared implementation identity for a custom adapter. --- Source: docs/HTTP_AND_MCP.md Page: https://affordance.rs/docs/http-and-mcp.html # Fetch, HTTP bindings and MCP Contract authority: [public contracts](../spec/CONTRACTS.md), [language](../spec/LANGUAGE.md) and [harness](../spec/HARNESS.md). Applications use global `fetch(input, options)` without an import. It returns a promise for an HTTP `Response`. The host supplies network access through the existing executor boundary; MCP is ordinary TSX composed over Fetch. ## Global Fetch ```tsx const response = await fetch("/items", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({name: "example"}) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const result = await response.json(); ``` Fetch runs inside effects and handlers, where external results can be journaled. HTTP errors such as 404 resolve with a Response; network or permission failures reject with TypeError. The HTTP executor's deadline produces TimeoutError, and runtime cancellation produces AbortError. The runtime's outer deadline retains its existing executor-timeout error. Global fetch explicitly uses the role **`http`**. Configure and grant that role in deployment: ```json "machines": { "http": { "kind": "http", "url": "https://api.example.com/v1/", "timeout_ms": 30000, "max_request_bytes": 8388608, "max_response_bytes": 8388608 } } ``` An instance's `machines: ["http"]` explicitly grants access. Without the `http` role, global fetch fails; it never guesses another binding. Existing defaults still apply: omitting an instance's role list grants all deployment bindings. Children can receive only roles their parent holds. Paths resolve against the configured URL: `items` uses its base path and `/items` starts at the origin's root. Absolute URLs must have the same scheme, hostname and port. Fragments are removed by Fetch. URL credentials are rejected. The permission covers the entire origin, not just the base path. CONNECT, TRACE, TRACK and app-controlled routing/framing headers are blocked. The host rechecks these limits, regardless of the TSX facade. Requests currently support `method`, `headers`, `body`, `redirect` and `signal` (an `AbortSignal`: aborting it cancels the request, which rejects with `AbortError`). Bodies are text; string conversion and the default `text/plain;charset=UTF-8` content type follow Fetch. GET and HEAD cannot carry a body. Headers accept a record, pairs or a Headers instance. Unsupported options fail explicitly before I/O. The host's no-redirect policy blocks default redirect following. Redirect statuses reject unless `redirect: "manual"` is requested. HTTPS verifies certificates. The executor keeps up to eight idle connections per host for 30 seconds, without a cookie jar, automatic application retries or proxy-environment inheritance. Cancellation drops the active request; dropping the executor releases its pool. Defaults are 30 seconds and 8 MiB per body, in addition to the runtime's outer `call_timeout_s` limit. Oversized bodies reject rather than silently truncate. The current transport accepts UTF-8 response bodies. ## Response and Headers Supported Response properties are `ok`, `status`, `statusText`, `url`, `redirected`, `type`, `headers` and `bodyUsed`. `text()` and `json()` consume a non-null body once; `clone()` must be called before consumption and gives an independently consumable copy. HEAD and null-body statuses have no body. Fetched headers are immutable. Reading a response or parsing JSON adds no external effect: the underlying HTTP result is already journaled. Global `Headers` supports construction, `get`, `has`, `set`, `append`, `delete`, `forEach` and `getSetCookie`. Names are normalized and validated; repeated headers are preserved and combined as the Web API specifies. Global `Response` supports text/null construction, `clone()` and `Response.json(data, init)` for tests and application-defined fetch functions. These are a documented subset, not a claim of full browser Fetch compatibility. Request, URL and Headers iterator APIs, ReadableStream, binary bodies, cookies and CORS are not implemented here. Accessing a non-null `response.body` stream fails explicitly; use `text()` or `json()`. Responses are buffered until complete, so headers and progress are not delivered before the body. Keep Response objects local to handlers; persist parsed data. The standard-library implementation is readable JavaScript in `src/web.tsx`, embedded in the engine. Applications can shadow the public globals; its internal classes retain their own identities. Node differential tests exercise the supported semantics. ## Named capabilities and injected Fetch For another origin or credential scope, use a named function rather than extending RequestInit: ```tsx import { createFetch } from "@affordance/lib/http.tsx"; const fetchDocs = createFetch("docs"); const response = await fetchDocs("/search", {method: "POST", body: "example"}); ``` The deployment must bind `docs` to that instance. `createFetch` shares the global Fetch implementation and security checks. An app or library can accept this function as a normal argument. No credential scope is selected implicitly from a URL. ## Credentials Deployment headers are protected: application requests cannot replace them, even with different capitalization. Headers can be executor-owned literal values or environment-derived: ```json "headers": { "Authorization": {"env": "MCP_TOKEN", "prefix": "Bearer "} } ``` The environment source is the actual caller's `CallContext.env`. Supply the value through deployment/agent `env` and list its key in `secrets` for persistence/redaction controls. These values remain readable by TSX through `harness.env`. For credentials that must stay outside app code, keep the protected header value exclusively in the host executor's configuration, outside the app environment. Bindings do not hide credentials from another broad filesystem/shell permission granted to the same app. Keep credentials out of URL query strings: `response.url` exposes the requested URL. Headers are inserted in the host, outside model-visible definitions and Fetch arguments. Transport errors omit URLs, headers and bodies. Server responses may contain sensitive data and are journaled as returned. Tool visibility in a view does not replace server authorization. ## MCP as a Fetch library ```tsx import { mcpClient } from "@affordance/lib/mcp.tsx"; const client = mcpClient("/mcp"); const server = await client.discover(); const {tools, rejected} = await client.listTools(); const search = tools.find(tool => tool.name === "search"); const reply = await client.callTool(search, {query: "installation"}); ``` For a named binding: `mcpClient("/mcp", {fetch: createFetch("docs")})`. An injected function must follow Fetch's input/options/Response shape; it can also be an ordinary TSX test stub returning `Response.json(...)`. The MCP library uses no machine or executor APIs itself. The earlier role-based `mcpClient(role)` now takes an endpoint URL. Deployments using the global function grant `http`; other role names use an injected `createFetch(role)`. Discovery returns data and installs no prompt instructions or affordances. The app owns filtering, naming, caching and refresh. Tool pagination follows up to 100 pages and rejects repeated cursors and duplicate names. Invalid tool definitions are excluded and reported in `rejected` as `{name, reason}`. Passing a discovered definition to `callTool` lets the library honor `x-mcp-header` annotations. Schema references are preserved, never fetched. The server remains responsible for full JSON Schema validation. `callTool` preserves content blocks, structuredContent, isError and metadata. Tool-reported errors remain ordinary results. Protocol, RPC and HTTP failures throw objects named McpProtocolError, McpRpcError and McpHttpError respectively, with message and relevant code/data/status fields. An `input_required` result is returned unchanged; the app can call again with `{inputResponses, requestState}` as the third argument. No sampling, roots or elicitation capabilities are advertised automatically. The library targets **MCP 2026-07-28 Streamable HTTP**, using JSON or finite SSE responses. It handles request metadata, header mirroring, SSE comments and multiline data. Live subscriptions, stdio, older initialize/session protocols and automatic OAuth flows are outside this version. Servers must finish response streams within the configured time and body limits. Edit `views/demo/mcp/affordance.yaml` to use your endpoint, model server and tokenizer. Its `expose` prop maps selected server tools to app-selected affordance names. The example prints raw MCP results as JSON and retains structured results in action metadata; media blocks are not automatically rendered as model media. Changing `url` or `expose` hides the old catalogue and pauses new conversation calls until discovery finishes for that configuration. Late results and failures from an older discovery cannot replace the current catalogue or stop the new configuration. The example enqueues its initial `task` once. Already issued model/tool calls retain the view and endpoint they captured. ```sh affordance run views/demo/mcp ``` ## Official JavaScript SDK alignment The official [TypeScript SDK v2](https://github.com/modelcontextprotocol/typescript-sdk) accepts a `fetch` function in its client transport options. This is the interface used here. The published `@modelcontextprotocol/server` 2.3.1 package is also an optional interoperability oracle: a real SDK server serves discovery, tool definitions, structured results and tool errors over JSON and SSE to the Affordance app's global Fetch. **The full SDK does not run unchanged inside Affordance yet.** Its package imports and transitive dependencies need npm/ES-module loading. Its client also needs additional Web APIs, including URL, AbortController, streaming body readers and TextDecoderStream. The current work aligns the supported Fetch contract and protocol wire behavior; it does not substitute our smaller client for the complete SDK's API. Install the optional development oracle outside the shipped runtime: ```sh npm install --prefix target/mcp-sdk-oracle --ignore-scripts --no-audit --no-fund \ --save-exact @modelcontextprotocol/server@2.3.1 zod@4.6.7 cargo test --release --test mcp official_javascript_sdk_server ``` Without those packages the SDK-specific test reports that its optional oracle is unavailable. Node's native Headers/Response differential and all Rust fixture tests remain independent of those packages. `AFFORDANCE_MCP_SDK_DIR` selects another installation directory. ## Replay and the host interface Fetch uses the same journaled machine effects as the underlying HTTP executor. Committed results replay without network access; an operation without a committed result can repeat on recovery. Neither HTTP nor MCP provides exactly-once execution. The library does not retry tool calls automatically. Use server/application idempotency for changing operations. Rust hosts bind `affordance::connectors::http::Http::new(HttpConfig)` to `http` or another role using `Runtime::bind_machine`. The `http` Cargo feature is enabled by default; renderer-only embedders can omit its HTTP/TLS dependencies with `--no-default-features`. The global TSX standard library remains available without that host adapter. The lower-level executor contract remains available for host integrations: a machine call accepts `{path?, method?, headers?, body?, timeout_ms?}` and returns UTF-8 `{status, statusText, url, headers, body}`, with lowercase header names and arrays of values. A raw call's timeout may shorten the deployment limit, never increase it. ```sh cargo test --release --test fetch --test http_connector --test mcp ``` --- Source: spec/HARNESS.md Page: https://affordance.rs/docs/harness.html # The harness contract Affordance has two parts. **The language** (`spec/LANGUAGE.md`) turns state into exact token ids for a model, plus the actions it offers. **The harness** (this document) runs apps the way a browser runs pages: it decides what is mounted where, which model and machines each app may use, how its events and effects run, how effects reach the world, and how all of it survives a crash. The runtime never calls a model on its own: an app calls it (§3). This document is normative for `src/runtime/`. "Is" and "must" describe what the code does. Sections marked *not built* describe what it does not do yet. For ownership, document/computation boundaries and failure guarantees, see [CONTRACTS.md](CONTRACTS.md). Contract version: **8** (`CONTRACT_VERSION`, pinned in every journal). Version 8 pins a model binding by which model answers (kind, model, provider options, template, context), not how it is reached (docs/MODEL_BINDINGS.md); version 7 returned inspection paths as arrays of key segments. A journal of another version requires that version's binary to resume. ## 1. Objects | Object | What it is | |---|---| | **Serving** | `affordance serve` delivers each HTTP request to the first instance as the event `request` (`{id, method, path, query, headers, body}`); the app answers with calls on the machine `client` (`{respond: id, status, headers, body}`, or streamed: `stream: true`, then `chunk`s, then `end`). An embedder posts events into a running runtime with `Runtime::inbox()`; each is journaled by the instance that takes it. See docs/RUNTIME.md. | | **Frontends** | `affordance host` runs a deployment for a frontend over stdio (JSON lines): prompts and aborts are the events `user` and `abort` to the first instance; a model's reply and a command's output stream out as they are produced (the observer's view, never journaled); the machine `ui` is the frontend (a call with `ask` waits for its answer). `Inbox::close` ends a run as closing a page does. See docs/RUNTIME.md. | | **Deployment** | The bindings a run starts with: model servers by name, machines by role, the run directory, limits. An `affordance.yaml` for `affordance run` (`docs/RUNTIME.md`), or a `RuntimeConfig` in Rust. | | **App** | A `.tsx` file and its imports. Its entry is the requested function, else the file's default export. Its identity is a hash of the entry name and every loaded file's relative path and contents (`View::source_hash`). A served app imports only from its own directory (and below) and from the library (the deployment's `library`, by default the nearest directory above the root app holding `index.json`, the installed `views/`), as a page loads scripts only from its allowed origins (CSP `script-src`). Paths are resolved through symlinks before the check; an import outside is a load error. | | **Instance** | An app mounted with props and bindings. It owns its state, journal and children. Its id is its path (`root/alice`). Every running thing is an instance: an agent, a root that mounts a fleet, a child of another app. | | **Props** | The instance's inputs from its parent, in its state as top-level fields. They change only through `Props` inputs from the parent. | | **State** | Props plus hooks (`useState`). A hook is named (`useState(init, "fold")`, stored as `__ui["@fold"]`) or positional (keyed by component path and call order). A hook's initial value is stored the first time a render uses it (state is fixed at mount, as in React), so `useState(prop)` does not follow later props. The instance is the only writer of its state. | | **Log** | The harness keeps no conversation. An app that keeps a history of its model turns keeps it in its state: `views/lib/log.tsx` (`useLog`) writes `screen`, `model` and `action` records from `model()` and `dispatch()`, with exact ids, plus the `input` records the app appends, and releases what its prompt no longer shows to the store. | | **Journal** | The ordered inputs of the instance, written before each takes effect and committed in groups (§7). Replaying it rebuilds the instance exactly. | | **Binding** | Authority to use something outside the instance: a **model** (by name) or a **machine** (by role). An instance can reach only what it is bound to. | ## 2. The instance tree An instance's render produces three things: 1. **Tokens**, for its model (when it has one). 2. **Affordances** (``): tools the model calls, and callbacks the harness calls. 3. **Mounts**: its children. A mount produces no tokens: ```tsx import { Greeter } from "./greeter.tsx"; setGreetings([...greetings, g])}/> ``` `` takes exactly one component element and records it instead of rendering it: the file that defines the component and its name are the child's app and entry; its props are the child's props. `key`, `model` and `machine(s)` are the parent's decisions about the child's bindings. After every input, the instance reconciles its children with its latest render. A new key mounts a child. A missing key unmounts it. Changed props are a `Props` input to the child. A changed app, model or machines remounts it. Reconciliation is a deterministic function of state, so replay mounts the same children. A remount starts a fresh incarnation, including hook and store state. Recovery after a process restart restores the current incarnation instead. Old incarnations are preserved under the run directory's `.retired/` archive. Child keys cannot start with a dot or contain path separators or control characters. Inspection cursors are scoped to the incarnation, so reusing a child key produces a fresh state read. **The boundary.** The child runs as its own instance, so the element crosses a boundary, as a React Server Component's props cross from server to client (and as a portal's child lives elsewhere). Unlike an in-process component: - **Props are JSON.** A function, markup, a token or a regex as a prop is a render error naming it, as is a value that contains itself or nests past 128 levels (as state). A function prop named `onX` is a callback (below). The child takes no children. - **Callbacks are events.** Calling one in the child returns nothing; the parent's handler runs later, in the parent's own loop. - **The child renders on its own schedule** (its model turns), not the parent's. Only changed props pass from the parent's renders. - **Nothing is shared** but props and callbacks: no context, no state. **One owner per state.** Props flow down. A callback prop (`onGreet={...}`) arrives in the child as a stub. Calling it is an event in the parent (`mount::onGreet`), which runs the parent's handler with the call's arguments. It is delivered even if the calling handler finishes or the child stops. When a child stops, for any reason, the harness calls its parent's `onStop` with the stop value. Callbacks are queued notifications. Their recovery boundary is the parent's journal: a crash or forced shutdown can lose a notification not yet journaled there. Use an acknowledged `harness.send` when the sender needs confirmation of durable delivery. **Bindings only narrow.** A child's machines must be a subset of its parent's. Both the parent's reconciliation and the host check this. A child's model is the one `` names, else its parent's. Top-level instances are bound to the deployment's machines unless their spec lists roles. With a model binding, an instance calls its model when its code says so (`model()`, §3); without one it only reacts. Children live on their parent's worker. Each journals under `///`. On resume, a parent's replay rebuilds its children set, and each child restores from its own journal and first props. ## 3. The loop **Prepared documents.** Event delivery reuses the last prepared document while state, app, tokenizer and the render's observed context fields remain valid. Direct `harness` field/index reads observe that field; escaping the context object conservatively observes all fields. Render-time date helpers observe the clock. Reused event snapshots receive current globals, preserving unchanged field identities; model-turn and running-task snapshots stay immutable. `AFFORDANCE_RENDER_VERIFY=1` checks reuse against fresh token output, mounts and effect declarations. Runtime reports include diagnostic `render_stats.evaluations`/`reused`; these are process-local work counters, separate from persisted replay metrics and excluding extra verification renders. An instance is a page: it renders a view of its state, reacts to events, and calls its model when its code decides to, as a page calls `fetch`. The runtime never calls the model on its own, and keeps no conversation. Each instance applies one input at a time: journal it, apply it, reconcile children, report a stop. Applying an input (a reply, a result, an event, a tick, props): 1. settles what it answers and resumes the tasks waiting on it; 2. runs the listeners of an event (``, `mount:` callbacks) at once, even while other tasks wait on effects; 3. renders, and runs the effects whose deps changed (`useEffect`: cleanups, then setups), again while setups change state, at most 50 passes (else it stops with `max_update_depth`). A task (a listener, an effect setup or cleanup, a tool call) runs its synchronous prefix. **Opt-in input frames.** Deployment `input_batch` (1..64, default 1) coalesces already-queued events/results per instance in a bounded worker window. A group is one `{"input":"batch", "inputs":[...]}` journal record, at most 1 MiB after blob lowering, written before execution or acknowledgement and committed like any record (§7). Oversized groups use individual records. Replies, ticks, props, control receipts, lifecycle operations and inspection are scheduling barriers; nested batches are invalid. No timer delays incoming work to fill a batch. Members run in order and drain their microtasks, with a fresh execution allowance per member. Listeners retain the frame's committed captures; functional setters see current state. Thus two `setN(n + 1)` calls using one captured `n` replace it with the same value, while two `setN(n => n + 1)` calls increment twice. Scheduled effects and reconciliation run at the frame boundary; explicit model/render calls still evaluate the requested document. Recorded batch boundaries govern replay. Agent `journal_len` counts logical members; JSONL line count counts physical frames. Checkpoint offsets and hashes remain byte-based. Existing individual records retain their semantics; older binaries cannot read the new batch shape. Every await yields to the instance's FIFO microtask queue. The current input's listeners run before that checkpoint. Await continuations, reactions and promise adoption share the queue; unawaited work remains owned by the task until it settles or the task is cancelled. A setter applies once to live state in execution order: a later write wins. An updater runs synchronously against the current frozen value. Suspended functions retain their locals and references; earlier writes and updater computations do not run again on resumption. Durable recovery uses a verified eligible checkpoint plus newer inputs, or re-executes the complete journal when no usable checkpoint exists (§8). Clock and randomness come from the deterministic instance, while host operations return promises. A journal replays only on a binary that implements the same contracts (`spec/CONTRACTS.md`). **Model calls.** `model(view?, opts?)` sends a view to the instance's model and resolves with an output: no view means the page's own, laid out once, at the call, from live state (its handlers are kept for `dispatch`); an element is an ad-hoc prompt (a summary, a sub-question). The output: `{text, reasoning, calls: [{name, args, id, problem?}], problems, cut_off, ids, screen: {ids, images?, content?} | null, provider_state?, usage: {prompt_tokens, cached_tokens, exact, generated_tokens}}`, with `ids` (and the screen's) as token runs. `cached_tokens` counts the prompt's prefix shared with the previous page prompt and its reply; an ad-hoc call is off that chain (`cached_tokens: 0`, `exact: false`). Hosted syntax also returns the screen content as sent, including image parts. Optional `provider_state` is opaque JSON text tagged by protocol/model, retained by useLog on model records and passed through logTurns. Deliberate edits must clear it. Hosted log records retain fields instead of a second copy of JSON reply IDs. `opts`: `max_new`, `sampling` (merged over the binding's), `tag`, `priority`, and `signal` (below). A prompt that does not fit the binding's `context` with its reply is not sent: the call fails with `ContextError: ...` naming the largest boxes. A page that fails to render (a component or template that throws, an image without a data source) rejects the call with that error, as `fetch` rejects: the app catches it and the instance goes on. A page over `max_prompt` stops the instance (`prompt_over_limit`); the `max_turns`-th page call stops it (`max_turns`). Any number of calls may be open at once. **Aborting a call.** `AbortController` and `AbortSignal` are the browser's: `model(view, {signal})`, `harness.machine.call(args, {signal})` and `fetch(url, {signal})` take one, and `controller.abort(reason?)` stops every call given its signal. A model call stops generating and resolves with what it generated, as a streaming `fetch` reader keeps the chunks it read: the output has `aborted: true` and `cut_off: true` (its text is kept; a call it began is not made). The partial reply is journaled with `aborted: true`, so a replay reads the same output. A reply that was already complete when the abort reached the model is journaled whole and resolves normally (`aborted` absent): the journal decides the race. A machine call (and so `fetch`) is cancelled and rejects with `AbortError`. A call given a signal already aborted is never issued; it rejects with `AbortError` at once. A model call aborted and not yet answered when the host stops is stopped again on resume (it answers at once with what it has) rather than issued whole. Signals are the instance's: whether one is aborted, and why (`signal.aborted`, `signal.reason`, `signal.throwIfAborted()`), is kept with the instance (journal, images), so a controller kept in state, which is frozen, still aborts. They are read and aborted in handlers. `AbortSignal.abort()` makes one already aborted; `addEventListener("abort")`, `AbortSignal.timeout` and `AbortSignal.any` are not supported. `sampling` is an object of provider sampling parameters (for example `temperature`, `top_p` and `seed`). Prompt/input IDs, messages, tools, model identity, native-token/stream protocol, stop controls, routing/session identity and output limits are host-owned. Attempts to supply those fields through sampling fail before network I/O; use explicit call options or bindings. Native model responses must contain unsigned 32-bit integer token IDs; malformed IDs are protocol errors, rather than being silently dropped or truncated. Adapters do not retry these errors; the instance-level retry policy below still applies. `dispatch(output)` runs a page output's calls on the handlers of the view the model saw; each call's return value is its result, several run concurrently, and it resolves with `[{name, id, result, error?, ...meta}]` in call order. A call with a `problem` (an unknown tool, bad arguments) is answered with it and not run. Dispatching the same output again returns the same results; an output's view is kept until it is dispatched or 64 newer page calls have been made. An ad-hoc output cannot be dispatched. Whether and when to call the model, and what to remember, is the app's (`views/lib/log.tsx`, `views/lib/loop.tsx`: `useLog`, `useLoop`, `useConversation`). The supplied `useLog` allows one open turn at a time: `call` rejects until the previous turn is dispatched or closed. `close(out, why?)` requires the resolved output of that turn; closing while `call` or `dispatch` is pending raises `InvalidStateError`. An older output's repeated close leaves a newer turn unchanged; dispatch through the log requires the current output. Separate logs and independent `model()` calls can run concurrently. The TSX library exports `LogRecord` and its input/screen/model/action variants, plus `recordText(record)` for diagnostic/search text (reasoning and calls included; image parts become `[image]`). It is not the prompt renderer. Each committed record has a library-owned `id`: `e0`, `e1`, … for the default log, or `:e0`, … for a named log. Caller-supplied IDs are overwritten. Held inputs receive IDs only when committed after the turn's results. `release` always keeps record IDs, while removing exact token `ids` from the stored payload. `read(from,to)` reads a nonnegative ordered half-open range, reconstructing released records; `get(id)` returns that log's full record or null. Read released records before `recordText`. ## 4. The harness API (in a handler) | Call | Result | Notes | |---|---|---| | `model(opts?)` | an output (see §3) | The page's own view to the instance's model. `{signal}` aborts it (§3). | | `model(, opts?)` | an output | An ad-hoc prompt: lays out a component to exact ids and runs it once on the instance's model, through the same admission gate, rank pinning, captures and cancellation. The component's `protocol` span reads the reply. A prompt over `max_prompt` is not sent; the call fails with an error the task can catch. | | `dispatch(output)` | `[{name, id, result, error?, ...meta}]` | Runs a page output's calls (see §3). | | `fetch(input, init?)` | `Promise` | Global Fetch over the bound `http` role. Supported options: `method`, `headers`, text `body`, `redirect`, `signal`. Headers, body readers and limitations: `docs/HTTP_AND_MCP.md`. | | `harness.machine.call(args, {signal}?)` | executor-specific JSON | The instance's first machine. An aborted `signal` cancels the call, which rejects with `AbortError`. Shell executors take `{cmd, timeout_s?, env?, stdin?}` and return `{output, exit, seconds, timed_out, truncated}`. Large strings cross as blob references (§7). | | `harness.machine.get(role).call(args)` | executor-specific JSON | A named one of several. `get` itself only names the machine; `call` issues the effect. | | `harness.send(to, payload, {event?})` | `{to, delivered}` | Event `message` (or `event`) to instance `to`, with `from` set by the harness (an operator's message has no `from`, so an app trusts control fields only without one). Completes when the receiver has journaled it. `mount:*` events are reserved (a ``'s callbacks). | | `harness.send(ids, payload, {event?})` | `{delivered, failed, undelivered}` | Array of target IDs, deduplicated in input order. One result after all recipient acknowledgements; counts plus unconfirmed IDs in target order. Empty arrays succeed with zero counts. Self/missing/failed targets fail as for a single send. | | `harness.wait({ms?, events?})` | `{woke: "timeout"}` or `{woke: "event", event, payload}` | Parks the task. Without `ms`, waits only for an event. It wakes at once on a matching event journaled after the render its task runs from (one the model had not seen). | | `harness.inspect(targets, {select?, since?, as?})` | `{since, agents: {[id]: {version, changed, changes?, status}}}`, or with `as: "text"` `{since, text}` | A read-only copy of other instances' committed state (`targets`: an id, a list, or `"*"`), made on their workers between inputs; nothing is written to their journals. `select`: named hooks and `"props"` (whole, `{key, set}`), or `{key, path?, tail?}` (a followed list: `{key, path, from, items, length}`). `since`: an earlier read's opaque cursor; only changes to selections covered by that cursor are omitted. New/reselected paths, whole/follow modes and tail windows receive their initial copy even at the same journal position. The next cursor covers this call's selections. Values are JSON tagged where JSON has no word (`{"$": "map", entries}`). Needs the spec's `inspect` grant (§5). | | `harness.store.put / get / search / list(args)` | `{key}`, `{text, offset, total}`, `{hits}`, `{keys}` | Per-instance storage. | | `harness.now()`, `harness.random()` | number | Logged values: replay sees the same. | | `harness.stop(value)` | — | Stops the instance. | | `Promise.all / race / any / allSettled` | as in JavaScript | Reactions run through the instance FIFO queue; `race` does not cancel other inputs. | **Data in a render:** - `harness.agent` (the instance id), `turn` (page calls so far), `clock_ms`, `env`, `prompt_tokens` (the last page prompt's length); - `machines` (the bound roles); - `owner`. Effects are allowed only in handlers. Live execution resumes saved frames through the instance queue; standalone transcript reconstruction compares every request exactly. A tool answers when its body returns (its return value is its result, like a Service Worker's `respondWith`); work it started and did not await goes on and settles later (like `waitUntil`). Inspection selections accept a dotted string path or an array of literal key segments. Response paths are always arrays: `["a.b"]` names one key, while `["a", "b"]` names two nested keys. Inspection list followers (`{key, path?, tail?}`) track appended items by length. They are for append-only lists; same-length replacement is not a list delta. Select the whole hook with a string key, or reset the cursor, when replacements must be observed. Serialization failures return a per-target `error` without advancing that target's cursor. Tool argument validation follows JSON Schema's object rules: `additionalProperties: false` rejects extra fields even with empty `properties`; omitting it permits extra fields. A schema in `additionalProperties` validates those extra values. Unsupported `schemaOf` types, including function-valued properties, produce errors rather than silently losing fields. ## 5. Machines A machine binding names an external executor: a shell, HTTP endpoint, or custom adapter. Machine calls are at-least-once: after a crash, outstanding calls are resubmitted, so commands with durable side effects should be idempotent per (instance, task). There is no other way out of the harness. Running on the harness's own host means binding a `local` machine. Network access is through a machine. | Kind | Config | Runs | |---|---|---| | `local` | `home?` (`{agent}` filled) | `bash` on this host with only the instance's environ, in `home` when given. | | `ssh` | `target`, `key?`, `control_dir?`, `known_hosts?` | `bash` on a VM over a shared connection. | | `docker` | `image`, `name?` | `bash` in a container per instance, started on first use. | | `bwrap` | `home`, `network?` | `bash` in a bubblewrap sandbox: host read-only, `home` writable, no network by default. *Tested only where bubblewrap is installed.* | | `command` | `argv`, `max_reply_bytes?` | A machine that brings its own executor: each call runs `argv` in its own process group, with `{agent, task, env, args}` as JSON on stdin, and takes a JSON result from stdout (at most `max_reply_bytes`, default 128 MiB: past it the call fails and the group is stopped). A cancelled call's group gets SIGTERM, then SIGKILL after 5 s. | | `http` | `url`, `headers?`, `timeout_ms?`, `max_request_bytes?`, `max_response_bytes?` | A generic HTTP executor on a bound origin. Calls take `{path?, method?, headers?, body?, timeout_ms?}` and return `{status, statusText, url, headers, body}`. Global Fetch uses the role `http`; named fetch functions use their assigned role. Protected headers are inserted by the host. See `docs/HTTP_AND_MCP.md`. | Local and bubblewrap home templates and Docker name templates replace `{agent}` with a safe, collision-free component: `i-` followed by UTF-8 bytes, retaining ASCII letters, digits, `-` and `.`, and escaping every other byte as `_hh` (including `_`). Example: `root/child` becomes `i-root_2fchild`, distinct from `root_child` (`i-root_5fchild`). Docker names are validated and names/images are passed as arguments. SSH target/key templates retain their documented raw ID substitution. Upgrading an existing local/bubblewrap deployment requires moving each old home to its new component path, or using an explicit fixed home; instance IDs and journals do not change. Docker deployments similarly need their old containers renamed if retaining them. `{n}` and `{id}` in a machine's config make one machine per agent. `count` makes a pool of roles (`vm-{n:04}`) that a root hands out. A call's `stdin` reaches the command byte for byte as its standard input; a command that does not read it never runs it as script. A `command` machine gets it as `args.stdin`. Output past `spill_bytes` is written straight to a blob (§7). Capture is bounded (16 MiB). **Capabilities.** An agent's `capabilities` (e.g. `["store.*"]`; a trailing `*` matches a prefix) limit the platform APIs it may call (`send`, `store.*`). A call outside them returns the error `this agent may not call `. Absent means all. Children inherit their parent's. `harness.inspect` is not among them: an agent's spec `inspect` lists the id prefixes it may read (`["*"]`, `["agent-"]`); it is empty by default and children do not inherit it. A spec with `inspectable: false` (inherited) is never read. Machines are bound by `machines`, never by capabilities. ## 6. Models A model binding is a name, a tokenizer and a client. Clients: - `sglang`: `/generate` with `input_ids`; - `completions`: `/v1/completions` with token-id prompts and `return_token_ids` (vLLM); - `chat`: a hosted `/chat/completions` (TrustedRouter, OpenRouter); - `anthropic`: a hosted Anthropic `/v1/messages`, with prompt-cache breakpoints; - `responses`: a hosted OpenAI `/responses`, with native reasoning and function tools. In a render, frozen `harness.model` is `{name, template, template_options}`, or null for an instance without a model. An unconfigured native template is null. These are presentation facts; endpoints, credentials and provider settings are not exposed through this object. `views/lib/model.tsx` exports `ModelContext`: it selects the binding's registered template, passes its options, and composes `TurnStrategy` through `log`, `strategy` and `callable` props. The app supplies `system` and children. Missing templates fail explicitly. Hosted histories render from fields instead of JSON reply IDs. A passive (`callable:false`) render keeps tool definitions but disables provider calls in all hosted adapters, after merging request settings. Provider call responses violate this restriction and fail explicitly, including partial calls. Explicit template imports remain available. Template selection/options are part of configured binding identity; imported template sources are part of the app source pin. Direct `Agent` users supply resolved `AgentConfig.template`. The stock CLI and `runtime::model::from_config` use one strict configuration contract. Unknown keys, wrong types, adapter-inapplicable settings and protected-field conflicts fail before inference. Each binding config is an object with these fields: | Fields | Adapters | Contract/default | |---|---|---| | `kind`, `url` | All | Required kind; base URL required except hosted Anthropic/Responses defaults below. Token-ID adapters use HTTP; hosted adapters use HTTP(S). No URL credentials/query/fragment. | | `tokenizer` | All, deployment metadata | CLI default falls back to `app.tokenizer`; each entry in `models` requires its own path. Rust callers supply the encoder. | | `sampling` | All | Object, default `{}`. Per-call sampling shallowly overrides defaults. | | `context` | All | The model's context length in tokens. Less the call's `max_new`, it is every prompt's window (`max-tokens: 80win`); a prompt that does not fit it with its reply is not sent (`ContextError`). A native binding asks its server when it is validated (`GET /v1/models`: the entry named `name`, or for SGLang the only entry, `max_model_len` or `context_length`; else SGLang `GET /get_server_info` `context_length`). A positive integer here may only lower that (more than the server reports fails validation); a server that cannot be asked leaves it as configured, with a warning. Hosted APIs report none: a hosted binding needs it. `"none"` runs without a window and asks nothing. A binding with no window otherwise fails validation. Part of the binding's identity (a window the server reports is the same identity as that window configured). | | `template`, `template_options` | All | Optional registry key (`views/template_apps/index.json`) and props object, default `{}`. Chat, Anthropic and Responses default to and require `messages`. Native clients do not guess a template. Options require a template and cannot replace app-owned `system` or `children`. | | `provider_options` | All | Object, default `{}`. Fixed per binding; copied to the top-level provider body. | | `key_file`, `key_name` | All | Both or neither for native adapters; both required for hosted adapters. A binding-local credential read at call time. | | `key_header` | Chat, Anthropic, Responses | The header the key is sent in: `authorization` (bearer), `x-api-key`, `api-key` (Azure OpenAI) or `x-goog-api-key`. Default: the API's own. Redacted like a bearer key; `headers` cannot name these. | | `request_timeout_s` | All | Finite positive seconds for the whole request; native non-streaming 5400, chat 600. SSE uses `stall_s`. | | `ranks` | SGLang, completions | Positive integer, default 1. | | `stream`, `stall_s`, `retries` | SGLang, completions | Boolean (default true); finite positive stall seconds (5400); nonnegative transport retry count (3). | | `sessions` | SGLang | False/omitted, true (4 turns/600 s), or `{turns?, timeout_s?}` with positive values and no other keys. | | `name` | Completions | Nonempty provider model selector, default `"default"`. | | `model` | Chat | Required nonempty provider model selector. | | `headers` | Chat | Optional object of non-secret string values. HTTP token names, valid header values, case-insensitive uniqueness; Authorization and Content-Type are client-owned. | | `stream` | Chat | Only false is supported; omitted means false. | | `model`, `headers`, `stream` | Anthropic | As for chat; `url` defaults to `https://api.anthropic.com`; `x-api-key` and `anthropic-version` are also client-owned headers. | | `model`, `headers`, `stream` | Responses | As for chat; `url` defaults to `https://api.openai.com/v1`; bearer auth. | | `strict_tools`, `retries` | Responses | Boolean tool-schema strictness (false); nonnegative transport resend count (3). | | `cache_ttl`, `retries` | Anthropic | `"5m"` (default) or `"1h"`; nonnegative resend count after HTTP 429/5xx (3), honouring `retry-after`. | Provider options cannot replace prompts/IDs/messages/tools, images, model selectors, request/session/routing IDs, authentication/destinations, stops, limits or stream controls. Sampling cannot set those fields or model/LoRA selectors. A key cannot occur in both provider options and binding/per-call sampling. Native SGLang `provider_options.lora_path` is a nonempty string or null for its single prompt. Other provider extensions are forwarded unchanged; the server validates their names and semantics. Local validation does not claim a universal schema for arbitrary provider versions. Credentials are not provider options and are redacted from request captures, never sent to the app or written into journal pins. Each SGLang session pins its client identity. Changing binding identity requires fresh sessions; per-call sampling may vary. Built-in clients declare identity through `Model::binding_identity`: the existing persisted instance pins include its digest and binding name. Strict recovery rejects changed or missing model identity before inference; warning recovery discloses the change and preserves original pins. Key rotation at the same file/name does not change identity. Custom adapters must declare their own identity to get this check. Already-recorded replies replay without another request; outstanding work retains the existing at-least-once contract. See [model binding examples and transport details](../docs/MODEL_BINDINGS.md). A `chat` binding renders through `MessagesApp`, whose JSON turns the client sends. It writes the reply back as `{reasoning, content, tool_calls}` (`syntax="chat"`). Its key is read at call time and never logged. Credentials stay file-only because agent tools inherit environment variables. Optional `headers` supplies fixed non-secret request headers (for example `HTTP-Referer` and `X-Title`); these are sent through curl's stdin configuration and included in request captures. `AFFORDANCE_CAPTURE_DIR` uses the same atomic request/response layout as native clients, with bearer credentials redacted, including provider echoes in diagnostic metadata. An `anthropic` binding follows the same rules with the Anthropic Messages wire; see [Anthropic Messages](../docs/MODEL_BINDINGS.md#anthropic-messages). A `responses` binding uses the same machinery for native OpenAI Responses, including opaque encrypted reasoning, provider call IDs, cache keys and usage. See [OpenAI Responses](../docs/MODEL_BINDINGS.md#openai-responses). Both native hosted adapters honor provider retry waits; their timeout is per HTTP attempt. No provider state is hidden outside the app log/journal. The host owns prices, spending limits and account state. Chat has no prices, dollar budget, reservation ledger or inferred cost. The former `price_in`, `price_out` and `budget_usd` configuration fields are rejected as unknown. Remove them from consumer profiles when upgrading; normal binding-identity recovery checks still apply. The existing `max_new` request limit maps to `max_tokens`; there is no cumulative token budget. Per-request logs retain local prompt/output token counts and provider cached-token observations. `model_meta.usage` preserves the provider's usage object (including any cost, currency or vendor fields) without interpreting it, subject to credential redaction. Provider token counts describe the provider's framing/tokenizer; local counts describe the IDs Affordance rendered/received. A valid response does not require a usage object or provider cost report. A failed call is a `ModelError`, and the client sets `transient`: - **Transient:** network failures, cut or stalled streams, a server's `abort` finish, cancellations, HTTP 429 and 5xx. A model call (the page's or an ad-hoc one) keeps its prompt and retries with capped backoff (1 to 30 s), indefinitely: a serving outage or restart is not the end of an agent. - **Otherwise:** HTTP 4xx (except 429), malformed replies (including one over the transport's size limits). A page call retries five times. Retries survive a restore. After them, the awaiting task sees the error as a rejected promise, which the app can catch (`useLoop` stops the instance with it: `loop_error`). The flag is journaled. *Not built:* readout bindings that score options (the d1, clef and clm prompts in `views/decision/` render the option and question spans such a binding would read); a human as a model binding. ## 7. Storage `harness.store.*` is per-instance key-value text, persisted to `store.jsonl`. Automatic `bN` keys skip keys already present, including explicit keys. Recovery requires string `key` and `text` fields and a nondecreasing unsigned `next` counter in every complete record. A complete JSON value with an invalid record shape is an error; only a syntactically torn final append is recovered as a torn suffix. With a run directory the store keeps each key's byte range in that file, not its text, and reads it back on `get` and `search`. **Memory.** Large strings are stored as mapped blobs, journals are streamed from disk on recovery, and render caches retain recent work. These reduce resident payload memory; they do not impose a fixed heap limit. App state, released-log skeletons, store indexes, and message IDs used for deduplication may grow with history. Apps choose their own retention policy. `log.release` moves record payloads to the store while retaining indexed skeletons. There is no `resident_bytes` setting or per-instance metric; the deployment CLI rejects one rather than silently ignoring it. Store operations serialize within one instance; another instance's store has a separate lock. A put publishes its key/counter/index once its complete record is written. With a run directory the record is synced by the next journal commit, before any line that learns the key is committed, and the commit records the store's length (below). A failed append truncates and syncs back to the previous file end. If rollback fails too, that space refuses further puts until a successful reload reconciles the file; existing indexed reads remain available. A failed put does not consume an automatic key. Fresh mounts archive old stores before loading their new incarnation. Without a runtime (`Store` used directly), store appends are synced before success is reported. Recovery preserves a torn final append in `torn-store-.bin` and retains the complete prefix. Invalid complete records are reported as corruption rather than silently discarded. **Journal commits.** A journal record is written before its input is applied, and becomes durable at a commit. A commit syncs every journal written since the previous one and then appends their lengths to `/commit.jsonl`. A commit starts as soon as something waits on one or a worker has nothing queued, and otherwise at most 2 ms after the first record it covers. Effects that leave the process wait for the commit of the input that caused them: model requests, machine calls, control receipts and the status files. A fresh mount over an old instance directory (which it archives) waits for the commit too. Deliveries between instances (`harness.send`, its acknowledgement, callbacks), timers, the instance tree and `store.*` operations do not wait. Store records and blob files are written without a sync; a commit syncs the store files and blob files written before it closed, with the journals, before its record. Every line that refers to a blob or learns a store key was written after that blob or record, so the commit covering the line covers the file. A recovery image syncs pending blobs before it is written. On recovery every journal and store is first truncated to its length in the last commit, or emptied if the commit does not name it for the instance's incarnation. The bytes past it are preserved in `uncommitted-journal-.bin` (`uncommitted-store-.bin`). A put of a text whose blob file exists checks the file's content once per process, so a file a crash left named but empty is rewritten rather than reused. A record is written only after every record that caused it, so the truncated journals are a state the run passed through. No receiver keeps a delivery whose send was lost, and no sender keeps an acknowledgement for a delivery that was lost. A crash loses only the work since the last commit, and nothing outside the process observed that work. A clean shutdown commits everything and closes the log; a resume after it (or of a run directory without a commit log) truncates nothing, so the journals stand as they are. Only the log's final line may be torn (an interrupted commit); a bad line before it is an error naming its byte offset, and recovery truncates nothing. Each truncation is reported on stderr with the instance id and byte counts. An instance whose `agent.json` cannot be read is left as it is and is not resumed; a journal whose last record cannot be read fails that instance's restore alone. A runtime owns its run directory: before recovery it takes an exclusive lock on `/runtime.lock` and holds it until the run returns. A second runtime over the same directory fails to start ("in use by another runtime") and touches nothing. The lock dies with its process, so a crash leaves nothing to clear. Root and child input journals use the same recovery policy as the store: preserve and truncate a terminal incomplete append before replay and subsequent writes. This includes a write cut inside a UTF-8 character. Complete records with invalid shapes, invalid UTF-8 bytes, and syntax errors that are not an incomplete suffix fail without discarding those bytes. A valid final record missing its newline is completed before appending. Preserved journal tails use `torn-journal-.bin`; repeated recovery retains the committed prefix. ## 8. Determinism, replay and versions A render is a pure function of the app, the tokenizer, the state, the `harness` snapshot, the clock and referenced blobs. A handler is deterministic given its effect transcript. The journal holds every input. Persistence requires a configured run directory; without one, execution is in memory. Replay reconstructs computation, not live sockets, processes or a JavaScript heap. Outstanding external calls can run again (§5); cancellation does not undo remote writes. Deployment environment and credentials must be supplied again on restore. **Recovery checkpoints.** With a run directory the host may write `/checkpoint.json`, an optional recovery cache. It keeps the complete journal and all historical data. A compatible image restores supported data/metadata at a complete input boundary, then applies only newer journal inputs. An image is of an idle agent (no pending execution, cleanup closures or undispatched views) or of an agent at a **step boundary**: the point inside an input where the work that ended has drained and the effects pass it asks for has not run, so nothing executes (a `useLoop` agent reaches one at the end of every step). A step-boundary image records that the pass is pending; restore runs it, exactly as the original did there, before the newer inputs. The agent keeps its latest step boundary in memory (shared values, no copy) and the host writes it when an image is due. Stopped agents may omit execution metadata that can never run again. Functions/modules, class instances, promises, collections and rendered markup fall back to full replay, as does any object a `memoScan` step or the turn cache made that is reachable from state or effect dependencies (a restored agent's cold cache would make a new object where the original reused that one); scans whose outputs stay in the render do not prevent an image. Opaque token buffers without markup/spans are supported as data. Shared data identity, frozen flags, undefined/numeric distinctions, environment/token-namespace references, effect dependencies, change notes (`harness.inspect`), counters, clock/randomness and dispatch results are preserved. The host writes an image after 256 additional inputs (of the agent if idle, else of its latest step boundary), and at shutdown and stop, so a pause images every agent that reached a step boundary and its resume replays at most the current step's inputs. Instances with configured secret environment values do not use recovery caches. Checkpoints require the same app source, entry, tokenizer, contract, complete AgentConfig, replay mode, spill threshold and model template; a model server's address, credentials, ranks and sampling are not part of the identity. The cache validates its checksum, the journal's last 64 KiB before the image's offset (so an image belongs to its journal) and the existence of every blob the journal refers to (recorded as lines are journaled); an unusable cache falls back to full replay. Restoring from an image reads only the journal after it: validation and receipts cover that suffix, and the last clock comes from the journal's tail. A full replay still reads and validates the whole journal. Cache-write failure leaves the durable journal authoritative. Checkpoints neither migrate older formats nor serialize suspended stacks or host resources. `snapshot.json` remains diagnostic; the root run's `checkpoint.json` remains its shutdown report. **Pins.** `agent.json` records `pins: {contract, app, tokenizer}` from the first start. Each journaled reply carries the hash of the prompt it answered. On resume, the first turn whose prompt the current app no longer renders is reported (`diverged_at`). With `replay: "strict"` the resume is refused. A journal pinned to another `contract` is always refused: its inputs mean something else under this one, so it resumes only on a binary of its own contract. **The clock is logical.** It resumes from the saved clock, so time while stopped does not expire waits. ## 9. Limits | Limit | Default | Effect | |---|---|---| | `max_new` | 4096 | Tokens per model turn. | | `max_turns` | none | The instance stops (`max_turns`). Children inherit it. | | `max_prompt` | none | A prompt over it is never sent; the instance stops (`prompt_over_limit`). Children inherit it. | | Fuel | 10M steps | Loop iterations and calls per render or handler run. Running out is an error, never a hung worker. | | Recursion | depth 400 | Catchable `RangeError`, including on runtime workers. | | Source nesting / expression chain | 256 recursive grammar levels / 256 links | Load error. | | State / inspection / `` prop nesting | 128 container levels | Explicit error; circular serializable graphs are refused. | | JSON.stringify nesting | 400 container levels | Catchable `RangeError`. | | JSON.parse nesting | 5000 container levels | Catchable `RangeError`; any call depth, any worker stack. | | Child list nesting | 400 levels | Catchable `RangeError`; a child list inside itself is a `TypeError`. | | String length | 536,870,888 code points (V8's) | Catchable `RangeError: Invalid string length`, before allocating. | | Bulk array length | 10M items | Catchable `RangeError` from a step that makes or grows an array by many items (`Array(n).fill`, spreads, `concat`, `split`, borrowed arraylikes); native flattening also consumes fuel. | | `call_timeout` | 3600 s | Per machine call. | | Exec capture | 16 MiB | `truncated: true`. | | `command` reply (`max_reply_bytes`) | 128 MiB | The call fails and the command is stopped (a JSON reply cut short is not a result); stderr is kept to 64 KiB. | | `spill_bytes` | 64 KiB | Strings past it live on disk. | | `model_inflight` | `model_threads` | Model calls in flight, balanced across ranks. Adjustable while running (§10). | | `restore_concurrency` | CPUs available | Instances restoring at once on resume; the rest wait. Bounds the boot peak to the steady heap plus this many restores. | *Not built:* storage quotas, per-minute call rates. ## 10. Operating a run Root specs accept `dedicated_worker: true`: allocate an additional render worker for that root and its mounted descendants, excluding other roots. `workers` still sizes the shared root pool. Placement applies to startup/resume membership; there is no dynamic root-spawn API. Reports expose the physical worker index as diagnostics, not app state. Which root to isolate is deployment policy; there is no built-in channel hub. Children retain parent-worker placement. Multicast uses the existing `send` capability and `_msg` replay deduplication. It is not an atomic multi-recipient transaction. A queued delivery dropped before commit reports failure; a completed aggregate result is replayed without sending again. Unfinished sends may repeat, and already-journaled receivers deduplicate them. Old sender incarnations cannot receive late acknowledgements. Journals are synced together by commits (§7), across instances and workers. With `dir`, the run directory is the operator's interface: | Path | Meaning | |---|---| | `/agent.json` | The instance's spec and pins. Secrets are removed. | | `/journal.jsonl` | Its inputs. **Sensitive:** tool outputs and messages are stored as they were. | | `commit.jsonl` | The journals' commits: each journal's committed length, by instance (§7). Resume truncates journals to the last one. | | `runtime.lock` | Locked by the runtime that owns the directory (§7). | | `/trace.jsonl` | Renders, replies, model calls (usage, cost), semantic tool completions, handler errors, stops. Secrets redacted. | | `/snapshot.json` | Diagnostic state and metrics at shutdown. | | `/checkpoint.json` | Optional verified recovery image and journal cursor (§8); distinct from the root shutdown report. | | `blobs/` | Large strings (§7). | | `clock.json`, `observed.json`, `checkpoint.json` | The logical clock, live status per instance, the last shutdown. The first two are rewritten about every 100 ms by a status thread (latest snapshot wins, so a slow disk delays neither pause handling nor the agents); the shutdown writes `clock.json` (`paused: true`) and `checkpoint.json` synchronously. An instance's status has its `state` (`starting`, `idle`, `model`, `tool`, `waiting`, `retrying`, `stopped`, `failed`), `healthy` and `handler_errors` (the errors thrown by its handlers, effects and callbacks, which no awaiter may see). While a model call retries a failure (§6), the state is `retrying` and `retrying` is `{failures, error}`: the failures so far and the last one's kind (`rate_limited`, `server_error`, `rejected`, `unreachable`, `timeout`, `aborted`, `cancelled` or `error`; never the server's address). A retrying instance is still healthy; otherwise `retrying` is null. `observed.json`'s `models` has each binding's window: `{name: {context, source}}` (§6 `context`; `source` is `server /v1/models`, `server /get_server_info`, `configured`, or `configured (the server reported none: ...)`; null without a window). | | `model_inflight` | One integer. Overrides the model admission limit while running. | | `control/pause.json` | `{mode: graceful \| fast \| immediate}` suspends the run (journals intact; `--resume` continues). Queued sends and inbox messages are not applied while it drains; their senders' calls stay outstanding and the resume delivers them (docs/RUNTIME.md, Serving and draining). | | `control/inbox/.json` | `{operation_id, targets, payload}` delivers an operator message. It is journaled as an event with `op`, and acknowledged in `control/receipts//.json`. Never delivered twice. | `AFFORDANCE_CAPTURE_DIR` captures every model request and response. Capture is best-effort, like a browser's network log: a failed write never fails or resends the call; it is logged, counted in `observed.json`'s and the run report's `capture_errors`, and leaves no file. ### Semantic tool completion traces The journal's clock (`clock_ms`) is logical: it continues from where a paused run stopped, so a pause takes no time from waits and timeouts. Each start and resume writes a trace record `{t:"clock", at_ms, wall_ms}`; until the next one, wall time is `at_ms + (wall_ms - at_ms)`. Every trace record has `at_ms`, its time in the journal's clock (`clock_ms`): when what it records began where that differs (a tool's start, a model call's start), else the input being handled. Records link a turn's work: a page call's `render` and `reply` carry its `turn` (and the render the `task` of the model call it issued, which the `generate` record carries), and a `tool` record the `turn` whose reply asked for it. At the first answer of a valid dispatched tool, the runtime emits one additive `{t:"tool", tool, at_ms, turn, dispatch, index, ms, status, timed_out}` trace, from `at_ms` for `ms`. `tool` is the canonical tool name; names over 128 Unicode characters use a 63-character prefix plus `#` and the full SHA256 of the name to remain bounded and distinct. `(agent, dispatch, index)` identifies the semantic call. `ms` is nonnegative logical elapsed milliseconds from actual dispatch, not the earlier clock captured by its view. It includes logical time passing across pause/recovery; it is not executor CPU time. `status` is `"error"` for a thrown handler error or, in a `{result, ...meta}` answer, explicit `error:true`, `timed_out:true`, or a nonzero integer `exit`; otherwise it is `"ok"`. Other returned values are ordinary result data, not metadata envelopes. `timed_out` comes only from explicit boolean metadata in that envelope; private text and exit codes alone are never interpreted as timeout evidence. Apps must preserve that metadata (the session shell does on both inline and stored-output paths). A normal `wait` deadline is a successful answer, not an executor timeout. Arguments, call IDs supplied by the model, result bodies and error text are excluded. Machine/store/send subcalls, callbacks and pending or rejected undispatched calls do not produce semantic completion events. An early answer is counted when returned even if background work continues; its later success or failure produces no second completion. Restore discards reconstructed traces and preserves outstanding work, so resuming completed calls does not append duplicates. Traces are best-effort diagnostics, not a transactional exactly-once lifetime ledger: a process crash between journal and trace writes can lose a completion. Consumers count completions in their bounded trace tail; old uninstrumented traces do not establish zero calls or complete coverage. Runtime source modules are supported through `loadModule` from `affordance:compiler` ([LANGUAGE.md §2.2](LANGUAGE.md#22-runtime-source-modules)). Store namespaces in hook state; recover source from journaled inputs. Pending functions retain their original lexical module across replacement. This uses existing journals, not a new persisted-code format. Nested source components share one instance; a file-backed wrapper may load source from props in a separate ``. Startup and runtime module initialization are both pure, and every function value owns its lexical environment. ## 11. Not built - **A parent showing a child's screen** inside its own render (a window in a window). - **Live network streams and app-facing AbortSignal.** Buffered global Fetch exists over a bound HTTP executor; it does not expose sockets or streaming bodies. - **Readout model bindings, human bindings, storage quotas, call rates** (§6, §9). --- Source: views/demo/research/README.md Page: https://affordance.rs/docs/example-research.html # Context policies example A small fleet on the instance runtime. Each agent answers a question by searching a document corpus through `bash`, on its own machine, under a context policy: The budget is the model binding's `context` (the window is it less `max_new`). Dropping and clipping are layout (`agent.tss`); summaries react to the layout event when a prompt passes `trig` of the window. | Policy | Rung | What happens | |---|---|---| | `none` | R0 | Nothing: the run ends when the context is full. | | `trunc` | R1 | Past 85% of the window, the oldest whole turns drop, in steps, down to about half (`history` rule). | | `compact` | R2 | One model call writes a handoff summary (`model()`); it replaces the turns. | | `tree` | R4 | Each fold writes a leaf summary; three leaves merge into one; the `recall` tool reads a past turn in full. | | `fold` | R5 | Context folding (FoldAgent): `branch` opens a side task, `return` folds its turns away to one summary; over budget in a branch, the agent is told to return. | | `lazy` | R3 | Any policy: `bcp_search` and `bcp_get_document` are native tools, loaded with `load_tool`. | - `agent.tsx`: one agent. Props: `question`, `policy`, `lazy`, `trig`, `template` (`messages` for a hosted chat API, `qwen36`, `glm53` for a local server), `onDone`. - `ladder.tsx`: the root of an experiment. It mounts one agent per (question, policy), each on its own machine, grades each answer against `expect` (`onDone`), and records runs that stop without one (`onStop`). Its state is the results table. - `tools/examples/search/` (development checkout only): `bcp_search` and `bcp_get_document` over a local directory (`BCP_CORPUS`, BM25). The demo uses this repository's `docs/`. The full BrowseComp evaluation, retrieval server, and judge live in the separate Affordance research project. This example uses the local documentation corpus. Its deployment is [affordance.yaml](affordance.yaml): set the provider and export its key as `API_KEY`, then `affordance run views/demo/research`. Paths in `env` are absolute: a local machine runs commands in the agent's home. The results table is the root's state (`.affordance/root/snapshot.json`). The policies come from `views/strategies/context.tsx`. The Recursive LM (R7) is its own demo, `views/demo/rlm/`; it does not use the `rlm` library the window-prototype rung ran (see git history before window.rs was retired). --- Source: views/demo/memory/README.md Page: https://affordance.rs/docs/example-memory.html # Permanent memory (after OptMem) An agent whose memory outlives its sessions, after VictorTaelin's [OptMem](https://github.com/VictorTaelin/OptMem), built as an instance tree. - `memory.tsx` is the root. It owns the memory: an append-only log of one-line memories (`@log`) and a binary tree of one-line summaries over it (`@tree`, where block `lo-hi` summarizes its two halves). It mounts one session at a time and moves to the next when a session stops. - `session.tsx` (`Session`) is one session. It wakes with the memory's cover and works on its task with four tools: - `note` records a memory (through the root's `onNote` callback); - `recall` is a regex search over every memory, word for word; - `zoom` opens a summary into its two halves; - `answer` ends the session. The cover is `cover` in `views/strategies/context.tsx`, OptMem's tiling. Recent memories stay verbatim and older ones collapse into tree nodes, in at most `wake_lines` lines. A block is compressed by asking for one line of at most N bytes that keeps what will still matter later and adds nothing. Where it differs from OptMem: - **No naps.** When a note completes a block of the tree, the root compresses it at once with a model call (`model()`), smallest block first. A session never stops its work to summarize. - **Write access is structural.** A session writes memory only through the `onNote` callback its parent passes it. A helper the session mounts gets no `onNote`, so it can read what it is given but cannot write. - **It is durable.** The memory is the root's journaled state, so a crash resumes the whole tree (root and current session) from the journals. - **A session wakes once.** `wake` is fixed when the session mounts (`useState(wake)`). Notes made during the session change the root's memory, not the session's first message. Props: `sessions` (one task per session), `template` (`messages` for a hosted chat API, `glm53`), `wake_lines` (default 96), `chars` (the maximum length of a memory or summary, default 280). Run it on a hosted model. Four sessions on DeepSeek V4.1 Flash took 27 turns and about 40K prompt tokens, under $0.01. The root needs a model binding (`root.model`) for its compressions. Its deployment is [affordance.yaml](affordance.yaml): set the provider's `url` and `model`, export its key as `API_KEY`, and run `affordance run views/demo/memory`. The memory ends in `.affordance/root/snapshot.json` (`state.__ui`), and each session's wake is in `.affordance/root/session-/snapshot.json`. The offline test is `a_memory_outlives_its_sessions_compresses_old_notes_and_recalls_them` in `tests/runtime.rs`. --- Source: views/demo/memory_server/README.md Page: https://affordance.rs/docs/example-memory-server.html # A memory longer than the context, for any OpenAI client A chat model behind an OpenAI-compatible endpoint (`affordance serve`) that keeps each conversation itself, in its journal. A client may send its whole history or only its newest message; either way the model sees the same thing: a system prompt with summaries of older exchanges (every four exchanges become a leaf summary, every three leaves merge into one), the last six exchanges verbatim, and the new message. A restart with `--resume` keeps every conversation. ```sh affordance serve views/demo/memory_server --set model.url=http://127.0.0.1:8080/v1 curl -s localhost:8000/v1/chat/completions -H 'content-type: application/json' \ -d '{"model": "m", "messages": [{"role": "user", "content": "My name is Ada."}]}' ``` The header `x-conversation-id` (or the request's `user` field) names a conversation; without one, every request is the conversation `default`. Streaming (`"stream": true`) returns the reply as one server-sent event, then `[DONE]`. The app is [app.tsx](app.tsx), about sixty lines: [views/lib/openai_server.tsx](../../lib/openai_server.tsx) turns requests into `reply({conversation, message})` calls; the app keeps the exchanges, folds the old ones with the model, and answers. `tests/serve.rs` runs it against a stand-in model server. --- Source: views/demo/rlm/README.md Page: https://affordance.rs/docs/example-rlm.html # Recursive Language Model A Recursive Language Model, after Prime Intellect's [RLM](https://www.primeintellect.ai/blog/rlm), built as an instance tree. The root model never reads the input. The input sits in a persistent Python REPL on the agent's machine, as `context`, and the model works on it with code through one tool, `python`. Code in the REPL can call sub-LLMs: `llm_batch(prompts)` runs them in parallel, and `llm_query(prompt)` runs one. The model answers by setting `answer["content"]` and then `answer["ready"] = True`. It sees what a cell prints, capped at 8192 characters. - `rlm.tsx` - `RLM`: the agent. - `Direct`: the baseline, which reads the whole context in its prompt. - The default export, `Bench`: mounts both per task and grades their answers into `@results`. - `tools/examples/rlm/rlm_repl` (development checkout only): the REPL, a daemon per machine home (`start FILE`, `exec`, `resume`, `stop`). It is an optional Python helper, excluded from the framework package, and makes no model calls. - `tools/fixtures/make_rlm_tasks.py`: Oolong-style aggregation tasks. Each is a log of user messages whose category (complaint, praise, question, request) is never written down, and each question aggregates over those categories (how many complaints, which user praised most, how many questions from one user). Answering means reading every line, not finding one. ## How sub-LLM calls work `llm_batch` parks the running cell, and the `exec` call returns the prompts. The `python` handler then: 1. answers every prompt at once with `Promise.all` over `model()`; 2. sends the replies back with `resume`; 3. loops until the cell finishes. So sub-LLM calls are the instance's own model calls. They are in its journal and under its budget, and on resume they replay instead of being called again. The REPL never sees a key or a model endpoint. ## Run ```sh python3 tools/fixtures/make_rlm_tasks.py 11 3000 > tasks.json # 3,000 messages, ~307K chars ``` Its deployment is [affordance.yaml](affordance.yaml): put the tasks in `root.props.tasks`, set the provider and export its key as `API_KEY`, then `affordance run views/demo/rlm`. `env.PATH` must be absolute, because a local machine runs commands in the agent's home. The results are in `.affordance/root/snapshot.json` (`state.__ui["@results"]`). The REPL daemons outlive the run, since they belong to the machines; stop them with `HOME= rlm_repl stop`. The offline test is `a_recursive_lm_works_on_its_context_in_a_repl_and_batches_sub_llm_calls` in `tests/runtime.rs`. --- Source: docs/HARNESS_GUIDE.md Page: https://affordance.rs/docs/harness-guide.html # Harness guide: driving an affordance app from Rust Contract authority: [public contracts](../spec/CONTRACTS.md), [language](../spec/LANGUAGE.md) and [harness](../spec/HARNESS.md). For a complete first render/action/rerender without a model server, start with [the walkthrough](GETTING_STARTED.md#call-an-action-from-rust) and [hello.rs](../examples/embedding/hello.rs). For harness authors who render an agent app to token ids, send them to a token-id endpoint (SGLang `/generate` with `input_ids`, vLLM `/v1/completions` with a token prompt), and feed the reply back. The worked example is `views/demo/consumer/` (one app, three template entries) and `tests/harness_consumer.rs`, which runs every snippet below. ## 1. Load once, render from many threads `Encoder` is `Send + Sync` (a compile-time assertion in `src/emit.rs` keeps it that way). Load the tokenizer once per process and share it. `View` holds `Rc` values and is `!Send`, so each render thread loads its own `View`, bound to the shared encoder: ```rust let enc = Arc::new(Encoder::from_file("ref/tokenizers/zai-org__GLM-5.3.json")?); for _ in 0..workers { let enc = Arc::clone(&enc); std::thread::spawn(move || { let view = View::load(Path::new("views/demo/consumer/glm53.tsx"), "Main", &enc).unwrap(); let mut cache = RunCache::new(true); // per thread, keep it across renders loop { /* view.render(&enc, state, &mut cache, now) */ } }); } ``` GLM-5.3, a 29.5K-token session, 20 renders per thread (`tools/bench/render_threads.rs`): | setup | 1 thread | 16 threads | |---|---|---| | `Encoder` + `View` per thread | 200 MiB | 3,099 MiB | | one shared `Encoder`, `View` per thread | 203 MiB | 240 MiB (1.18×) | The encoder accounts for about 196 MiB. Each extra thread adds about 2.5 MiB (its `View`, `RunCache` and allocator arena). Warm render p50 is 2.7–3.0 ms in both setups. ## 2. Tool names: `tool_name` A model-callable action is an `` with an `on*` handler. When `asChatTools` (inside `TurnStrategy`) projects it to a function schema, the name defaults to `verb__label`: `onRun` on `label="shell"` becomes `run__shell`. Set the name explicitly with `tool_name`: ```tsx { ... }}>{"[shell]"} ``` Other props: `description`, and `args_schema` (a JSON schema; otherwise the schema comes from the destructured parameters). Duplicate names get an `__h` suffix. The rendered affordance span has `label == tool_name`, so `View::act_effects(.., "shell", 0, args, ..)` dispatches the model's call by the name the model used. ## 3. Strings and fragments | Written as | Value | |---|---| | `"a" + b`, `` `a${b}` `` (plain template literal), `String(x)`, `JSON.stringify(x)` | string | | `` p`a${b}` `` (tagged prompt literal), JSX (`<>...`, ``), `tok.x + "..."` | fragment: text, special tokens and laid-out ids | A fragment keeps token boundaries and special tokens; a string is plain text and is tokenized where it is emitted. Read-only string methods (`startsWith`, `includes`, `indexOf`, ...) and `trim` work on fragments. Indexing (`c[0]`) is an error for a general fragment and names the fragment (see section 7). An exact token-run fragment supports numeric indexing of its token IDs. A fragment joins only with text: adding an object or a number to it is a TypeError, so convert first. In a `TurnStrategy` log: - **screen / input `content`, action `result`:** a string, a fragment, a list of content parts, or exact `ids` (section 5). GLM53App, Qwen3App, Qwen36App, Qwen3BaseApp, Llama31App, MiMo25App and MessagesApp accept fragments there (checked with `ids` records). Nemotron3App is unchecked: its tokenizer is not in this checkout. A map is rejected: serialize it first (`JSON.stringify(x)`). - **model `content` and `reasoning`:** strings. `calls`: OpenAI-style `[{"type": "function", "id"?, "function": {"name", "arguments": {..}}}]`, with arguments as JSON values. Use a plain string when you only need text. For exact token round-trips, log ids instead. ## 4. `TurnStrategy` log records `{app}` turns the log into chat turns. The current screen (the app) is the final user turn; an app with nothing on its screen (a plain conversation) adds none, and the prompt ends with the log's last turn. | `kind` | turn | fields read | |---|---|---| | `input` | user | `content` or `ids` | | `screen` | user (`unrolled` only; skipped by `last-turn`) | `content` or `ids` | | `model` | assistant | `content`, `reasoning` → `reasoning_content`, `calls` → `tool_calls`; or `ids` | | `action` | tool | `result` (text, or a list of content parts: `{type: "text", text}`, `{type: "image_url", image_url: {url}}`) or `ids`, plus `name` and `tool_call_id` | | `metric`, `event` | none (kept in the log, skipped) | none | Any other kind is an error naming the record (`TurnStrategy log[7]: unsupported log record kind "note"`). Every other field (`id`, `seq`, `pinned`, `exit`, timings, effect transcripts) is ignored, so you can pass your full event log. Records are plain JSON. Inside the view you can filter and rewrite the log before passing it in (the consumer's `selectLog`). ## 5. Exact ids: `ids` on records, and the current screen span Exact-append prefix caching needs the next prompt to start with `previous prompt ++ generated ids`. Text records cannot guarantee that: - templates normalize (GLM strips content, harnesses trim reasoning); - a decoded screen re-encodes differently across `` runs (in GLM-5.3, `[shell][send_message]` merges `][`); - a model may emit non-canonical tokenizations. Log ids instead. **Screens.** `Rendered::current_turn()` is the token range of the current screen's body (span label `turn:current`, without the template's role tokens). `current_turn_ids()` returns its ids: ```rust let r = view.render(&enc, state, &mut cache, now)?; log.push(json!({"kind": "screen", "ids": r.current_turn_ids().unwrap()})); ``` **Model turns.** Log the generated ids exactly as the server returned them, including the stop token: ```rust log.push(json!({"kind": "model", "ids": output_ids, "reasoning": parsed_reasoning, "calls": parsed_calls})); ``` The template app renders the turn as its generation prompt, the prefix `add_generation_prompt` appended to the prompt the model saw (GLM-5.3: `<|assistant|>`; Qwen3.6: `<|im_start|>assistant\n\n`), followed by `ids` verbatim: - With `ids`, `content`, `reasoning` and `calls` are **metadata only**. They stay in your log and are not rendered. - **GLM** stops on the next turn's opener (`<|observation|>` or `<|user|>`), which the following turn emits, so one trailing opener is dropped. - **Qwen** emits `<|im_end|>\n` after the ids, or only `\n` if the ids already end with `<|im_end|>`. - Exact model turns are supported by every stock template app except **MessagesApp**: GLM53App, Qwen3App, Qwen36App, Qwen3BaseApp, Llama31App, MiMo25App and Nemotron3App. MessagesApp rejects them with an error naming the record. `input`, `screen` and `action` ids work in the template apps listed in section 3. **Action results and inputs** may carry `ids` too, for example to replay a tool result exactly. **In a template**, a generated turn's `m.ids` is a token run, the same fragment as its content (4 bytes an id, not a list value per id). It reads like a list of ids: `.length`, `[i]`, `.at(-1)`, `.slice(0, -1)` (another token run), `.includes(id)`, spreading and `for … of`. `tokens(m.ids)` passes it through. **Images.** An `` in a model's prompt lays out its placeholder tokens, and the model request carries its `src`: SGLang's `image_data`, one per ``, in prompt order. A template renders an image content part that has a source as such an `` (DeepSeek V4.1: one `<|deepseek_image|>`, which the server expands, so the ids stay exact and the turn stays an exact append). A tool returns an image as content parts; its record keeps them. A screen record keeps its ``s as `images: [{at, n, src}]`. A source is the image's bytes: a `data:image/(png|jpeg|webp|gif);base64,` URL, or base64 PNG, JPEG, WebP or GIF bytes (sent as a data URL). A URL or a path, which the model server or provider would fetch or open for the agent, fails the call before it is sent (CSP `img-src data:`); a template still renders it (the ids stay exact). A binding that cannot send images (completions) refuses such a request. Requests with images skip sessions. SGLang's prefix cache stops at the first image, so an app that wants long contexts cached shows an image for a turn and then renders a text stand-in for it (app policy, like folding). **Reducing a record** (folding): `ids` take precedence over the text fields, so a view that rewrites old records must drop them. `{...e, reasoning: "", ids: null}` renders from the fields again. `tests/harness_consumer.rs` checks the invariant for GLM-5.3, Qwen3.6 and Qwen3-2507. With ids, `prev_prompt ++ generated` is a prefix of the next render on every turn of a 10-turn session. That session includes whitespace before ``, non-canonical generated ids, text-only replies, an injected input and adjacent `` labels. The same session logged as text breaks the prefix in every family. ## 5a. Incremental renders: `memoScan` An agent re-renders its whole history every turn. With `memoScan`, a pass over the transcript does the work of what is new: ```js const r = memoScan(items, init, (state, item, index) => [nextState, out], deps); // r.state: the final state; r.outs: [out, ...], one per item ``` - **Contract:** the step depends only on its arguments, declared dependency values and invariant module constants. Module constants are evaluated per document: include one in `deps` when its identity matters or its value can vary between documents. Dependency arrays compare entries with `Object.is`, like effect dependencies; their own identity is not a dependency. Other dependency arguments compare with `Object.is`. Omit `deps` or pass `null` when there are no captured dependencies. - **Reuse:** the initial state and each item compare with `Object.is`: primitives by value, objects, functions and fragments by identity. A fresh object safely causes recomputation. Kept inputs and results are frozen by the runtime; copy them to change their contents. For record/array accumulators, use a `null` seed and initialize it on the first step, as the shipped templates do; module constants are evaluated per document. When the inputs match, the kept states and outputs are reused for the matching prefix, and only the rest are stepped. Reused outputs are the kept values themselves, so a downstream scan over them hits too. - **Identity:** a scan is its call site, component path and order among that site's calls. - **Generations:** a scan not used in a turn is dropped at the next turn. - **Checking:** `AFFORDANCE_MEMO_VERIFY=1` also computes reused scans from scratch and compares result structure, including complete fragment spans. This diagnoses undeclared dependencies; it is not a proof of equivalence for arbitrary JavaScript objects or captured function environments. - **Without an agent** (a plain render, `affordance check`), `memoScan` is the plain fold. Lookahead becomes state. A message whose output depends on the next message is written when the next one arrives (DeepSeek V4.1's generation prompt after a user turn), or a block is held in the state until it ends (GLM-5.3's reordered tool results). The DeepSeek V4.1 and GLM-4.5..5.3 views and `views/agents/session.tsx` are written this way. `TurnStrategy` keeps each record's turn, by record. ## 6. Effects: the harness API for external actions The runtime (`spec/HARNESS.md`, `docs/RUNTIME.md`) runs this loop for you: model calls, exact-id records, messages, waits, machines, resume. Use what follows only for a custom host. A handler that needs the outside world awaits a harness request. The host serves requests by **API name** (`machine:`, `send`, `store.*`), so it never matches tool names: ```tsx { const r = await harness.machine.call({cmd: command}); return {result: `${r.output}\n[exit ${r.exit}]`}; }}>{"[shell]"} { const r = await harness.send(to, {text: text}); return {result: `delivered to ${to}`}; }}>{"[send_message]"} ``` Host loop for one tool call (`name`, `args` parsed from the reply): 1. `let step = view.act_effects(&enc, state, now, name, 0, &args, &harness, transcript)?;` This renders `state`, finds the handler by tool name, and drains its queued work until it needs external input. `step.effects` is the transcript (`Request::Call { app, args }`, where `app` is `machine:` (the default machine), `machine:`, `send`, `store.put`, ..., plus an `outcome` once served) and `step.blocked` lists the pending indices. 2. Serve each blocked request, on any thread or task. Nothing is held between steps. 3. When a result arrives, set `transcript[i].outcome = Some(Outcome::Ok(value))` (or `Outcome::Error(msg)`). Set `transcript[i].settled` to an increasing arrival counter, then call `act_effects` again. It reconstructs one execution and delivers those outcomes in arrival order through the same queue as the live Agent, either parking or finishing (`done == true`). 4. When `done` becomes true, `step.value` is the handler's return value: the tool result. Continue serving `blocked` requests if it started unawaited work. Append `{"kind": "action", "name": name, "result": ...}`. `Promise.all([...])` issues several requests at once (spec §7.2). A changed request on replay is a divergence error. Missing settlement counters use input order. Use `runtime::Agent` for long-lived execution: it retains frames and only outstanding requests between inputs. The standalone transcript API retains no execution between calls; `PreparedHandler::run_live` has been removed. `tests/harness_consumer.rs::harness_api_effects_with_a_slow_external_call` runs this for two agents. A machine command sleeps 300 ms on a worker thread while a `send` completes at once. The send is committed first, the command's result later, and the next render shows each result as a tool turn. ## 7. Errors Render errors keep the message as the first line, then add the view stack, innermost first, and the log record being rendered: ``` a fragment cannot be indexed: this slot expects a string or a list, got a fragment (did you use p`...`? use a plain `...` string, or a template that accepts fragments) at first_output (/tmp/t.tsx:4) at ToolTurn (/tmp/t.tsx:7) while rendering TurnStrategy log[2] (kind "action") ``` `affordance::error_message(&err)` returns the first part only. `affordance check` compares messages with it. Malformed records fail in `TurnStrategy` with their index. Examples: a missing `kind`, `ids` that are not token ids, a map as `content`/`result`, or exact model ids sent to a template app that does not support them. ## Rendering chat requests in a model server A model server that applies a Jinja chat template and then tokenizes can call the engine instead: `affordance::chat_render::ChatRenderer` takes a request's messages, tools and template arguments and returns the prompt's token ids, through the model's template view. ```rust use affordance::chat_render::{ChatRenderer, Options}; let renderer = ChatRenderer::json(Options::new("views/qwen/qwen35.tsx", "Qwen36", "tokenizer.json"))?; let ids = renderer.render(&messages, tools.as_ref(), &serde_json::json!({"add_generation_prompt": true}))?; ``` It keeps what it rendered, by conversation, so a request that extends an earlier one costs what is new; it owns its rendering threads and shares one loaded tokenizer. A server with its own message type passes a function from a message to the JSON a template reads (`ChatRenderer::new`), which runs only for messages it has not seen. A request the official template would reject comes back as `RenderError::Raised` with the template's message. --- Source: docs/ARCHITECTURE.md Page: https://affordance.rs/docs/architecture.html # Architecture Affordance has one engine, implemented in Rust. Applications and reusable application policies are written in the TSX subset described in [LANGUAGE.md](../spec/LANGUAGE.md). [CONTRACTS.md](../spec/CONTRACTS.md) maps the public boundaries and their guarantees. File modules isolate declarations and share values only through explicit imports/exports; functions and closures retain their defining scope during calls and replay. Runtime source loading (`loadModule` from `affordance:compiler`) uses those same owned lexical environments and the same executor; apps supply dependency namespaces and own revision selection. The renderer loads an app and its imports, evaluates it against state, lays out its token fragments, and returns exact token IDs plus the actions still visible in the final layout. Adapters project those actions into each model's protocol. Cropping never leaves an invisible action callable by the model. The agent state machine takes one input and returns commands. The host runs model requests, machine calls, timers, and message delivery, then feeds their results back as journaled inputs. Each instance owns its state and children. The host owns external I/O, permissions, and durable storage. Replaying the journal reconstructs the instance. Eligible idle/stopped data states can use a verified checkpoint plus the newer journal inputs; full history stays on disk. Conversation logs, folding, agent loops, and task-specific tools are TSX library or app code. The core provides the operations these policies need; it does not choose a task or training algorithm. Model servers and command executors are external bindings. `src/connectors/` supplies host integrations through the existing executor interface. The HTTP connector understands HTTP requests and responses; the MCP implementation is a TSX library over global Fetch (`views/lib/mcp.tsx`). The embedded standard library (`src/web.tsx`) provides Fetch, Headers and buffered Response behavior over the same executor boundary, without MCP in the engine. Apps choose which discovered tools become affordances. See `docs/HTTP_AND_MCP.md` for deployment and replay behavior. ## Repository boundary The public framework consists of `src/`, reusable `views/`, the language/runtime specifications, API guides, regression tests, and small examples. The one installed binary is `affordance`. Rust benchmarks are development examples under `tools/bench/`. Python in `ref/`, `bench/`, and test support is development tooling. It supplies independent reference outputs and tests interoperability; no Python process is required to use the Rust renderer or generic runtime. Examples that expose a Python tool do so explicitly through a machine binding, as an application choice. Their executable helpers live in `tools/examples/` in the development checkout and are excluded from the runtime package. The former `phase2/` was a research program: training, game emulation, evaluation controllers, and experimental model runners. It is now a separate sibling checkout, `affordance-research`. Its results do not define the framework's behavior or install dependencies. Historical phase names describe project milestones, not framework versions. A small Courier app remains as an example of state, layouts, and actions. Its training code, model weights, datasets, and game-specific executables belong to the research project. ## Release boundary `Cargo.toml` lists the files included in the framework source package. Development caches and run output are ignored. A release must pass the validation in `docs/DEVELOPING.md`; publication also requires project license and release metadata to be chosen by the project owner. --- Source: spec/CONTRACTS.md Page: https://affordance.rs/docs/contracts.html # Public contracts and ownership This is the map of Affordance's shipped contracts. [LANGUAGE.md](LANGUAGE.md) is normative for source evaluation and document layout; [HARNESS.md](HARNESS.md) is normative for instance execution and recovery. The supported HTTP/Fetch and MCP surfaces are specified in [HTTP_AND_MCP.md](../docs/HTTP_AND_MCP.md). Guides explain those contracts. Affordance implements a documented TypeScript/JSX subset. Familiar syntax is not a promise of complete ECMAScript, browser API, Node or npm compatibility. Supported features, intentional differences and unsupported features are listed in the language and HTTP specifications. ## Boundaries | Owner | Responsibilities | |---|---| | App and TSX libraries | State, conversation/history, control flow, context selection, tools, protocol clients such as MCP, and which actions a model sees. | | Language and layout engine | Source/module loading, owned lexical scopes, components, text/token values, budgets, spans and action attachment. | | Instance state machine | Applying one input at a time, tasks, effects, hooks, mounts, model calls requested by the app, and dispatch against a captured view. | | Host | Scheduling, journaling, external model/executor I/O, capability checks, cancellation and storage. | | Deployment | Models, executor roles, credentials, limits and whether a run has a persistence directory. | File and runtime source modules share explicit imports/exports, platform globals and documented rendering inputs. Runtime loading adds no I/O capabilities; apps own source acquisition and revision history. Instances share only the documented JSON props, callback events, messages and inspection interfaces. A library import does not grant host capabilities. Child bindings may narrow their parent's authority. Conversation policies remain libraries even when the engine supplies layout helpers they use. ## Document and computation A render is a pure computation from the app source, bound tokenizer, state, harness snapshot, logical clock and referenced blobs to exact token IDs, labelled spans/actions and child mount declarations. Render performs no external effects or state writes. Hooks may declare initial values and effects; the instance commits them through its execution loop after successful root evaluation/layout. Components and native elements share typed style computation and token box layout. Hiding/clipping affects presentation and model actions, not component/instance lifecycle. Removing an element cleans up its effects and local state; instance-wide named state remains explicit. Child mount declarations and harness callbacks survive presentation clipping. Structural `key` identity is independent of style labels. Plain text joins into tokenizer runs. Special tokens and token-budget boxes establish the specified boundaries. Layout determines which actions survive; hidden or partly clipped actions cannot be offered to the model. Already laid-out fragments preserve their IDs. Exact output is defined by those rules, not by equivalence to tokenizing an arbitrary flat string. A page `model()` call captures its laid-out view and handlers. `dispatch(output)` runs the handlers from that captured view, even if the current state has since changed. Dispatch of ad-hoc model views is unsupported. Output retention, repeated dispatch and validation follow the harness contract. An instance applies ordered inputs to its state and emits commands for its host. A function's synchronous prefix runs immediately; awaits and reactions resume through its FIFO microtask queue. Saved frames retain locals, operands, references and exception state. State writes apply once against live state. Recovery restores an eligible verified data checkpoint and feeds newer journaled inputs through the same executor, or replays the complete journal. It does not serialize suspended interpreter/native stacks or operating-system resources. The standalone transcript API reconstructs one execution and compares each request exactly. These are observable semantic contracts. Rust ASTs, interpreter frames, emitter items and memory layouts are implementation details, not a portable bytecode or stable binary ABI. ## Persistence and failure | Boundary | Guarantee and limit | |---|---| | Input -> instance | With a run directory, write and sync the input before applying it or dispatching its resulting commands. Without a run directory, execution is in memory. | | Completed effect -> recovery | A journaled result is replayed without executing the external operation again. | | External operation -> journaled result | Outstanding calls can run again after a crash. Execution is at-least-once; external writes need application/server idempotency. | | Store append -> caller | Sync the append before reporting success; recover the complete prefix after a torn final append. This is not a transaction spanning remote writes and journals. | | Instance -> child | Separate state and journal; JSON props and callback events cross the boundary. Remount starts a fresh incarnation; process recovery restores the current one. | | Process -> recovery | Rebuild from persisted inputs, optionally starting at a verified compatible data checkpoint. Unsupported live state uses full replay. Complete journals remain retained; host resources are not serialized. | | Version -> replay | Pin the harness contract, app and tokenizer. Refuse another contract version; report source/tokenizer changes and prompt divergence, refusing prompt divergence in strict mode. This is not automatic state migration. | Deployment credentials and environment are supplied again on restore. Changing code, bindings or environment can change future behavior. Cancellation stops participating host work; it does not roll back a remote operation that has already taken effect. ## Evidence `tests/hardening.rs` checks reproduced boundary violations, class lifetimes, lexical intrinsics, state admission and process-crash containment. The [security gate](../docs/SECURITY.md) checks dependencies and reviewed exposure exceptions. `tests/modules.rs` checks independent module scopes, import/export failures, closure/class behavior, schemas, source loading, mounts and effect replay. `tests/lifecycle.rs` covers source revision changes and recovery, and `tests/closures_freed.rs` checks reclamation. `tests/features.rs`, `tests/adapters.rs` and `tests/adapter_contract.rs` check document boundaries and action visibility. `tests/page.rs`, `tests/effects.rs` and `tests/promises.rs` check task, model and dispatch semantics. `tests/reliability.rs`, `tests/spill.rs` and `tests/pause_latency.rs` check recovery and lifetime boundaries. Storage/checkpoint tests also cover append rollback, per-instance locks, identity-preserving images, cache invalidation and parent/child suffix recovery. `tests/conformance.rs` compares exact IDs against cached official templates; `tests/js_differential.rs` compares the supported language subset against Node. These are finite conformance checks. They do not establish full JavaScript/library compatibility, arbitrary-input exactness, or performance on unmeasured workloads. --- Source: docs/VERIFICATION.md Page: https://affordance.rs/docs/verification.html # Verification (draft) Some properties of the engine are proved, not only tested. This page lists each one: the claim in a sentence or two, the exact statement the tool checked, what the proof assumes, and what it does not cover. A claim that cannot be said in a sentence or two is not listed here. Two tools are used. [Verus](https://github.com/verus-lang/verus) proves statements about Rust code for every input, with no bound on sizes. [Kani](https://github.com/model-checking/kani) checks statements for every input up to a stated size. Neither is part of an ordinary build or of the PR gate. ## What is checked, and how it relates to the code that runs The arithmetic that fits a box to its token limit is `src/fit.rs`. Layout (`Live::close` in `src/emit.rs`) calls it for every box it closes. Verus reads that same file: there is no separate model. The specifications are attributes under `cfg(verus_keep_ghost)`, which only Verus sets, so `cargo build` compiles the functions as plain Rust with no new dependency. ```sh ./tools/verify.sh # Verus on src/fit.rs; expects "20 verified, 0 errors" cargo kani # the bounded harnesses in src/emit_proofs.rs ``` `tools/verify.sh` finds Verus through `VERUS=/path/to/verus`, then `PATH`, then `~/.local/share/verus/*/verus`. Verus is a release binary from its GitHub releases; it names the Rust toolchain it needs (`rustup install ...`). The proofs were last checked with Verus `0.2026.11.02.8bf68ee` (toolchain 1.99.1) and Kani 0.68.0. The proofs were also checked not to be empty: five deliberate bugs in `src/fit.rs` (a clip that keeps one token too many, a scroll viewport that ignores the marker's room, a drop that does not clip its last item, a marker that is not cut to its room, padding one token past the size) each make Verus fail. ## Claim 1: a box never emits more tokens than its limit **The story.** A box with `max-tokens` or `tok-size` never puts more tokens in the prompt than that limit, whatever its content, its `more` marker and its overflow policy (clip-end, clip-start, clip-middle, elide, scroll, drop-start, drop-end, drop-children, with or without `drop-step`). There is no exception. **The statement proved.** `a_box_emits_at_most_its_limit` in `src/fit.rs`. It is the sequence of calls `Live::close` makes (`room`, `over`, then one of `whole`, `scroll`, `drop`, `clip`, then `assemble`), with everything layout obtains elsewhere left arbitrary: the content's tokens, `tok-size`, the cap in tokens, the policy, the scroll offset, the item boundaries, the step, and both answers of the tokenizer (the marker's length at its longest, and the marker's tokens). For all of them: > if the box has a limit `lim` (its `tok-size`, else its cap), the tokens it emits number at > most `lim`. The proof also establishes, for every function in the file, that no index is out of range and no arithmetic overflows, and that `assemble` returns exactly the shown ranges of the content, the marker cut to its reserved room, and padding, in the box's order (`Fit::assemble`'s postcondition, `Fit::output`). **What the proof found.** The first version of this claim had an exception, and the proof would not go through without stating it. A drop box decides whether it overflows on its *measured* length: its content with graded results (`result:nth-last(...)`) counted at their static length, so that grading never moves the cut. A sheet whose graded rule makes a result *larger* than its static style made the measured length smaller than the real one, and a box whose measure was within its limit was laid out whole, over the limit: with `history { max-tokens: 1545tok; overflow: drop-start; }`, `result { max-tokens: 6tok; }` and `result:nth-last(n+5 / 3) { max-tokens: 400tok; }`, a history of forty tool results laid out to 1865 tokens. The fix is one condition (a box is over when its measure *or its content* is over its limit), the claim is now proved without the exception, and the case is a regression test (`a_history_cap_holds_when_old_results_grow` in `tests/turn_spans.rs`). **Assumed (trusted, not proved).** - *The glue in `Live::close`* (`src/emit.rs`, about eighty lines around the calls): that the limit it passes is the box's (`tok-size`, else `max-tokens` resolved against the window), that it passes the content it split off, and that it appends the tokens `assemble` returns unchanged. It is short and has no arithmetic on lengths left in it except the two below. - *`Room::drop`'s precondition*: the item boundaries layout passes never decrease and end at or before the content's length, and there are as many measured boundaries as boundaries. Layout builds them from the positions of segment marks, in order; this is not proved. No other function the glue calls has a precondition on its data. - *After the box closes*: nothing later adds tokens inside it. A box is a tokenization boundary (its content is encoded on its own, and text after it starts a new run), and an enclosing box can only remove tokens from it. This is not proved. - *The tokenizer is not assumed to be anything*: the marker's tokens and length are arbitrary in the statement. The count is of the token ids the engine emits; whether a model server counts the same ids the same way is outside the engine. - Verus itself, the Z3 solver, the Rust compiler, and Verus's specifications of the standard library calls used (`Vec::push`, `extend_from_slice`, `with_capacity`, slicing, `saturating_add`/`sub`/`mul`, `Option::unwrap_or`). Running out of memory is not modelled. **Not covered.** - *A box with no limit.* `max-tokens: 80win` when the model binding states no context length is no cap (a page with no viewport is not clipped). A box with neither property has no limit. - *The prompt as a whole.* Layout does not bound the whole prompt; boxes do, where a sheet puts them. What the runtime guarantees instead is a check, not a layout property: a prompt that with its reply's room is over the model's context is never sent, and the call fails with a `ContextError` naming the largest boxes (`Agent::over_context` in `src/runtime/agent.rs`). That check is one comparison and is not verified here. With no stated context there is no check. - *A hosted (Messages) history.* `history { max-tokens: ... }` on a hosted binding drops whole units before serialization (`drop_history` in `src/turns.rs`). It uses the verified cut (`drop_cut`) but never clips the unit it keeps, and it measures JSON with the bound tokenizer, not the provider's: the cap is approximate there and no bound is claimed. - *Spans.* Where labels, actions and nested boxes land after a crop (`rebase`) is checked by Kani up to a size, not by Verus. `assemble` is proved to report each kept range correctly (the output holds that range of the content at the reported offset), which is what `rebase` starts from. - *The values of style properties.* Parsing a sheet, the cascade, and `win` and `%` arithmetic (`Live::resolve_size`, `Step::tokens`) are outside the proof. The claim is about the limit in tokens that reaches layout. **The gap between the story and the theorem.** The story says "puts in the prompt"; the theorem says "`assemble` returns". The difference is the trusted glue and the two unproved facts above (the boundaries are ordered; nothing is added to a closed box). The story says "its limit"; the theorem's limit is the number layout computed from the sheet, not the sheet's text. ## Claim 2: a drop box drops whole items **The story.** A box with `overflow: drop-start`, `drop-end` or `drop-children` makes room by removing whole items. It never cuts into an item it keeps, unless it is down to the single item it must keep and that item alone is too big (`drop-children` keeps none in that case, so it never cuts). **The statement proved.** The postcondition of `Room::drop` with `drop_cut`'s (`cut_spec`) in `src/fit.rs`. For a box over its limit whose items are measured as they are (the measured boundaries equal the real ones: no graded result inside), with `(lo, hi)` the cut returned: > the number of tokens shown is exactly the length of the content between boundary `lo` and > boundary `hi` (and the range shown starts at boundary `lo`, so it is those items); or the mode > is not `drop-children` and `hi - lo` is the minimum it keeps (one item). and, on any boundaries: `lo <= hi`, both are in range, `drop-end` keeps a prefix (`lo == 0`) and the other modes a suffix (`hi` is the last boundary), and at least the minimum is kept. **Assumed.** As for claim 1. In addition the statement excludes sizes that cannot occur (the content's length plus the limit over `usize::MAX`). **Not covered.** A drop box with graded results inside: the cut is chosen on measured lengths, so kept items may then be clipped to the limit (this is what keeps claim 1 true for it). That the cut is the *best* one (keeping one more item would not fit), that it equals the simple one-item-at-a-time loop, and that with `drop-step` it only moves forward as content is appended: Kani checks these in `src/emit_proofs.rs` for up to 8 items and 64 tokens, not for all sizes. The error raised when a cut would fall inside a token two items share is in the glue. ## Checked up to a size (Kani) `src/emit_proofs.rs` holds harnesses for `drop_cut` and `rebase`, each over every input within its bounds (up to 8 items, lengths up to 64 tokens, every step up to 130): the cut is in range, on the right side, fits unless only the minimum is left, is maximal without a step, equals the reference loop without a step, is the first boundary past the stepped amount, and only moves forward as items are appended; rebased spans land inside the output. These are evidence, not proofs for all sizes; the first of them is subsumed by claims 1 and 2. ## Not attempted: replay "Replaying a journal, or restoring a checkpoint and replaying the newer inputs, reaches the same state as the original run" is the property the runtime is built around ([HARNESS.md](../spec/HARNESS.md) section 8) and it is tested, not proved. Nothing on this page supports it. --- Source: docs/SECURITY.md Page: https://affordance.rs/docs/security.html # Dependency security The release gate runs `python3 tools/check-security.py`, using `cargo-audit` and the RustSec database. Install the auditor with `cargo install cargo-audit --version 0.22.2 --locked`. New advisories fail the check. Python is development tooling; it is excluded from the published Rust package. The 2026-10-23 audit updated rustls to 0.23.46, rustls-webpki to 0.103.16, webpki-roots to 1.0.10 and Tokio to 1.53.3. These cover the reported TLS/certificate advisories and Tokio's reported unsoundness. The optional benchmark profiler is updated to pprof 0.15.0. Seven remaining advisory matches have narrow exceptions in `tools/security-exceptions.json`, reviewed on 2026-10-23 and due for review by 2027-01-21: | Dependency and advisory | Reviewed exposure | |---|---| | zip 8.6.0: [0367](https://rustsec.org/advisories/RUSTSEC-2026-0367.html), [0368](https://rustsec.org/advisories/RUSTSEC-2026-0368.html), [0369](https://rustsec.org/advisories/RUSTSEC-2026-0369.html), [0429](https://rustsec.org/advisories/RUSTSEC-2026-0429.html) | Optional `model` → Candle 0.11.0. These affect ZIP extraction to files, permissions and symlinks (0429: `extract` sizing a symlink target from its declared size). Affordance's CPU loader accepts `.safetensors` and calls `VarBuilder::from_mmaped_safetensors`; it does not invoke ZIP, pickle or NumPy loaders or archive extraction. Candle's dependency cannot be upgraded to patched zip 9 within its published constraint. | | quick-xml 0.26.0: [0194](https://rustsec.org/advisories/RUSTSEC-2026-0194.html), [0195](https://rustsec.org/advisories/RUSTSEC-2026-0195.html) | Optional `profile` → pprof 0.15.0 → inferno 0.11.21. These affect XML reading, duplicate-attribute checking and namespace allocation. Our benchmark programs call `Report::flamegraph` on profiler-generated data. Inferno writes SVG using `quick_xml::Writer`; this path does not parse XML or use `NsReader`. | | paste 1.0.15: [0436](https://rustsec.org/advisories/RUSTSEC-2024-0436.html) | An unmaintained build-time procedural macro in the optional Candle dependency tree. This is a maintenance exception; there is no patched release to select. | These are exceptions for Affordance's reviewed uses, not claims that those dependencies are fixed or safe for arbitrary use. The check verifies exact versions and registry checksums, hashes the reviewed CPU loader and profiler callsites, rejects newly introduced direct uses of affected APIs, and expires the review. Changed dependency boundaries or callsites require another review; the guard is not a formal whole-program reachability proof. Remove exceptions when upstream publishes compatible fixes. On 2026-10-29 the boot, fleet and turn benchmarks moved template selection into model bindings. The profiler calls and data sources above were unchanged; their reviewed file hashes were refreshed after checking those configuration-only diffs. Dependency pins and review expiry stay the same. For a reproducible local run with an already fetched database: ```sh CARGO_AUDIT=/path/to/cargo-audit \ AFFORDANCE_RUSTSEC_DB=/path/to/advisory-db AFFORDANCE_SECURITY_OFFLINE=1 ./tools/check.sh ``` Ordinary release checks fetch the current database. The offline options are for local reproduction; an old database cannot establish the absence of newly published advisories. --- Source: docs/DEVELOPING.md Page: https://affordance.rs/docs/developing.html # Developing Affordance Contract authority: [public contracts](../spec/CONTRACTS.md), [language](../spec/LANGUAGE.md) and [harness](../spec/HARNESS.md). The framework uses Rust 2024 and needs Rust 1.89 or later (`cargo +1.89 check --all-targets --all-features`). Building and using it does not require Python. Development conformance checks additionally use Python 3.14+ reference tooling and Node 22+ as independent oracles. Their dependencies are isolated in the local development environment. ## Validate `./tools/check-engine.sh` runs the engine regression gate on a fresh checkout, without model downloads or reference outputs. These tests use a tiny generated byte tokenizer. They cover Node differential semantics, Promise completion ownership, crash isolation on worker stacks, retained memory with live-owner controls, repeated root/child recovery, suspended reference identities, and overlapping log/MCP lifecycles. Node 22+, curl and Python 3 (for the inspection interoperability fixture) are required. The gate also rejects incomplete conformance inventories using a small offline CLI fixture. Model-binding fixtures also inspect actual HTTP requests, authentication, sessions, retry/cancellation and strict CLI recovery. The gate runs all library unit tests, including store append rollback/isolation and verified checkpoint images, plus memo and inspection regressions. These use the same small tokenizer fixture as the other engine tests. Real-vocabulary checks still need their optional local tokenizers; the complete release gate supplies the cached reference inventory below. The Promise suite runs the same small programs in Node, the live Agent, and transcript replay, then checks journal restoration. It includes FIFO await/reaction/adoption ordering, expression evaluation across awaits, loops, destructuring and exception/finally behavior. Lifecycle checks also exercise promises shared by handlers, cancellation and suspended markup. Run this gate before every commit. The complete gate below also generates independent model/template oracles, installs the published MCP SDK oracle, and audits the lockfile. After reference setup, `./tools/check.sh` runs the complete local gate, including the package boundary check and the [dependency security check](SECURITY.md). Install `cargo-audit` first (`cargo install cargo-audit --version 0.22.2 --locked`). Individual checks are: ```sh python3 tools/check-security.py cargo test --release # needs no reference data cargo test --release --features reference-tests # also the tests against ref/ (below) ./tools/test-parallel.sh # the same suite, its programs run 16 at a time (about a minute) cargo check --offline --all-features --all-targets cargo check --offline --no-default-features --all-targets cargo fmt --all --check cargo clippy --offline --all-features --all-targets -- -D warnings ./target/release/affordance lint ``` The box-fitting arithmetic (`src/fit.rs`) is also proved with Verus, outside this gate: `./tools/verify.sh` after a change to that file ([VERIFICATION.md](VERIFICATION.md) says what is proved and what is assumed). `cargo test` on a fresh clone needs no downloads: the tests that read the reference data under `ref/` (real model tokenizers, official template outputs, conformance corpora) are reported as ignored, with the reason, unless `--features reference-tests` is given. With the feature they fail loudly when the data is missing; none passes by skipping. The full conformance suite needs the cached tokenizers and reference outputs. On a new checkout: ```sh uv sync .venv/bin/python ref/fetch.py .venv/bin/python ref/cases.py .venv/bin/python ref/heldout.py heldout1 1 200 .venv/bin/python ref/heldout.py heldout2 2 300 .venv/bin/python ref/heldout.py heldout3 3 300 clean .venv/bin/python ref/make_agent_loops.py for set in dev heldout1 heldout2 heldout3 agent_loops; do .venv/bin/python ref/render_ref.py "$set" .venv/bin/python ref/render_ref_pyenc.py "$set" .venv/bin/python ref/render_ref_mistral_common.py "$set" done # The per-family held-out references (ref/new_out), from the official templates: for family in deepseek glm kolibri llama mimo mistral muse openai qwen; do .venv/bin/python tools/review_references.py "heldout_$family" done mkdir -p ref/new_out && mv ref/out/heldout_* ref/new_out/ ``` The middle-layer references (`ref/middle_out`) come from `tools/middle_references.py` ([middle layer](MIDDLE_LAYER.md)). The download consists of tokenizer/template files, not model weights. Additional held-out reference sets can be generated with the scripts in `ref/`. Tests disclose unavailable optional oracles; a release gate must use the complete cached reference sets and Node. `affordance check` requires a reference for every selected model in `views/index.json` and every case in the selected case set, with valid token IDs or an error string. Missing or malformed references fail before rendering. Use `--only` to request a deliberate subset; `--ref-dir` changes the location, not the expected inventory. Additional family sets use the model groups in `ref/models.txt`, independently of the files downloaded. Generation scripts may report individual download failures; these cannot turn into a passing complete conformance run. For another reproducible differential run: ```sh JS_FUZZ_SEED=20261023 JS_FUZZ_COUNT=2000 cargo test --release --test js_differential ``` Documented differences from JavaScript are listed in the language specification. The generator excludes those operations from exact comparisons; fixed corpus cases test their declared behavior separately. When fixing a bug, reproduce it first and identify the shared invariant it violates. Add an adjacent case that would expose another copy of the same mistake: reference kinds plus live owners for collection, callback holders and computed/optional references for calls, every byte boundary plus repeated appends for recovery, and reversed completion order for async state. New reference-bearing value kinds must appear in the exhaustive graph in `src/heap.rs`; new callable kinds use `Value::is_callable`. Passing examples establish those cases, not a proof of correctness for the whole language. Keep intentional language differences explicit. Prefer observable behavior specifications to implementation snapshots. A useful case states what an app can observe: the value or error it receives, which actions survive layout, or the state recovered after an input sequence. Reuse the existing corpus and fake-host runners; one scenario can run against an independent oracle and recovery without three separate test implementations. Avoid pinning allocation counts, AST shapes, request keys or incidental traces unless they are the contract being tested. When replacing a mechanism, retire checks of the old mechanism; do not keep adding layers of regression scaffolding. `tests/modules.rs` checks lexical file scopes and replay independently of model-template fixtures. Keep normative behavior in `spec/CONTRACTS.md`, `spec/LANGUAGE.md` and `spec/HARNESS.md`; guides link to those authorities. Historical proposals must identify their status and point to the current contracts before presenting older APIs or implementation claims. File imports now require explicit bindings: replace a global include such as `import "./library.tsx";` followed by `helper()` with `import { helper } from "./library.tsx";`, and export `helper` in that library. Bare imports load a dependency but introduce no names. Migrate wrappers and registered external consumers together; source pins report the resulting source changes through the existing replay rules. ## Current platform evidence The complete runtime is currently exercised on Linux (Ubuntu), with Rust 1.90.0; the minimum supported Rust version is 1.89. | Surface | Current evidence and constraints | |---|---| | Renderer / library | Linux builds pass, including `--no-default-features`. Other targets have not been validated; the crate also compiles runtime modules. | | Agent runtime / model clients | Unix: file APIs, process groups and signals, `bash`, `curl` and (for SSH machines) `ssh`. Nothing needs Linux: a command's time limit is the runtime's own, and without `/proc` the process table is read with `ps`. | | Machines | `local`, `ssh`, `docker` and `command` run on any Unix. `bwrap` (bubblewrap) is Linux only and says so elsewhere. A guest or container still needs coreutils `timeout`. | | macOS | Written to run there and built and tested by CI (`.github/workflows/ci.yml`, the `macos` job); less exercised than Linux. Two tests that read `/proc` are Linux only. | | Windows / WASM | No support. A renderer-only build for WASM needs a build boundary between the engine core and the host/runtime modules. | ## Releases Three workflows in `.github/workflows/`, each with one job to do. Every action is pinned to a commit SHA, and no registry token is stored. | Workflow | Starts when | Does | |---|---|---| | `ci.yml` | A pull request, or a push to `main` | The gate (`tools/check-engine.sh`) on Linux, a check on the minimum Rust version, build and test on macOS. Never tags or publishes. | | `release.yml` | A version tag is pushed | Builds the binary for Linux and macOS (x86_64 and arm64) and attaches the archives and checksums to a **draft** GitHub release. | | `release.yml`, by hand | Actions > release > Run workflow | A dry run: the same builds, kept as workflow artifacts. Nothing is tagged or released. | | `publish.yml` | The draft release is published | Publishes to the registries, after approval in the `release` environment: the crate to crates.io, and the prebuilt binary to PyPI (`affordance`, one wheel a platform) and npm (`affordance-rs`, which selects `@approximatelabs/affordance--`). `tools/package-registries.mjs` builds those from the release's archives; the dry run builds and tries them too. | To release: bump `version` in `Cargo.toml` and add the entry to `CHANGELOG.md` in an ordinary commit; `git tag vX.Y.Z && git push origin vX.Y.Z`; read the draft release and its archives; publish it; approve the `publish` run. There is no schedule: a release is cut when it is ready. One-time setup (repository settings and registries, not code): an environment named `release` with required reviewers; a tag protection rule for `v*`; and on crates.io, a trusted publisher for this repository, the workflow `publish.yml` and the environment `release`; the same on PyPI for the project `affordance`, and on npm for `affordance-rs` and each `@approximatelabs/affordance--` package (npm sets a trusted publisher on a package that exists, so each one's first version is published by hand, once). ## Benchmarks and output ```sh cargo run --release --example turn_bench -- --help cargo run --release --example adapter_bench cargo run --release --example middle_demo -- --sample ``` Benchmark examples are development programs, not installed product binaries. `bench/` contains Python reference timing tools. Conformance reports and benchmark output go under `target/`. Runtime runs, downloaded tokenizers, and build output are local ignored artifacts. Keep small independent regression fixtures under `tests/fixtures/`. Do not add training studies, model weights, emulator drivers, or experiment archives to the framework. Maintain those in the separate research project and consume Affordance through its public library, CLI, and TSX APIs. ## Environment variables Affordance reads only variables named `AFFORDANCE_*` (besides the standard `RAYON_NUM_THREADS`, `CANDLE_NUM_THREADS`, `MALLOC_ARENA_MAX` and `MALLOC_TOP_PAD_`). None changes what a view renders or how a journal replays; they add capture, checking, diagnostics or test targets. | Variable | Effect | |---|---| | `AFFORDANCE_CAPTURE_DIR` | Write every model request and response (and stream event) there, per agent ([model bindings](MODEL_BINDINGS.md)). | | `AFFORDANCE_ROOT` | The source checkout `affordance`'s development commands use. | | `AFFORDANCE_RENDER_VERIFY` | Check every reused render against a fresh one. | | `AFFORDANCE_MEMO_VERIFY` | Recompute reused scans and compare them ([harness guide](HARNESS_GUIDE.md)). | | `AFFORDANCE_INPUT_TIMING`, `AFFORDANCE_RESTORE_TIMING` | Print where replay and restore time goes. | | `AFFORDANCE_FLAMEGRAPH` | With `--features profile`: write a flame graph of the run to this path (`AFFORDANCE_FLAMEGRAPH_HZ` sets the rate in `turn_bench`). | | `AFFORDANCE_DTYPE`, `AFFORDANCE_NO_REUSE`, `AFFORDANCE_FORWARD_TIMING` | The in-process CPU model: `bf16`/`f16` weights, no prefix reuse, per-op timing. | | `AFFORDANCE_NO_FASTTOK` | Use the tokenizers crate's pre-tokenizer instead of the fast path (for benchmarks). | | `AFFORDANCE_TEST_THREADS` | Test threads for `tools/check*.sh` (default 2). | | `AFFORDANCE_SGLANG_URL`, `AFFORDANCE_STREAM`, `AFFORDANCE_MCP_SDK_DIR`, `AFFORDANCE_ANTHROPIC_SMOKE`, `AFFORDANCE_RESPONSES_SMOKE` (with `..._KEY_FILE`) | Opt-in live tests against a server, SDK or hosted API. | | `AFFORDANCE_RUSTSEC_DB`, `AFFORDANCE_SECURITY_OFFLINE` | `tools/check-security.py`'s advisory database and offline mode. | --- Source: CONTRIBUTING.md Page: https://affordance.rs/docs/contributing.html # Contributing Start with [your first app](docs/GETTING_STARTED.md), then read the [public boundaries](spec/CONTRACTS.md). Affordance is currently an experimental Rust framework with a TSX application language. Python reference tools belong to development; training and research applications belong to the separate research project. For a small engine or documentation contribution: 1. Describe the observable problem, expected behavior and a minimal example. 2. Locate the owning layer: application policy stays in TSX, external I/O stays in the host, and generic language/platform primitives live in the engine. 3. Make the smallest coherent repair. Consolidate the shared mechanism when a bug has several copies; avoid another special case or compatibility wrapper. 4. Verify the behavior through the existing corpus or scenario runner. Prefer an independent oracle and an adjacent boundary case to implementation snapshots. Remove obsolete checks when the mechanism changes. See [the validation guide](docs/DEVELOPING.md). 5. Run `./tools/check-engine.sh`. It needs Node and curl, but no downloaded models or Python reference environment. Template changes also need the full conformance gate documented there. Explain what changed, why, and what you ran in a pull request. Do not include local caches, model weights, run directories, or generated study outputs. Public contract changes should include their design argument and documentation in the same contribution. ## Changing the platform The runtime, `harness.*`, effects, journal and replay semantics, and the app-facing contracts are the platform, held to the standard of a core web API. Before changing them, write the argument down (a design note for a large change, the pull request for a small one): [docs/DESIGN.md](docs/DESIGN.md) shows the test applied to decisions already made. 1. **The browser parallel.** What does the web platform use for this need (the event loop, `setTimeout`, `fetch` with `AbortController`, `Promise.race`, `postMessage`)? How would a web app solve it with standard APIs alone? 2. **Where it lives.** Most needs are compositions of existing primitives: show the composition first. App behavior belongs in a view or library; a deployment's policy belongs to the program that runs Affordance. Only a genuinely missing primitive belongs in the engine. 3. **The smallest general primitive.** Generic (no product, vendor or one client's feature), useful beyond the case at hand, deterministic under replay, and consistent with cancellation, pause and restore. 4. **The standard cases still read naturally:** a chat bot, an autonomous tool-using agent, a multi-agent fleet, and a resumed journal. If one needs a workaround, the change is wrong. Affordance is licensed under [Apache-2.0](LICENSE); a contribution is offered under the same license. No stable Rust ABI, complete JavaScript/npm compatibility, or cross-platform runtime support is promised yet.