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