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.
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 chunks, 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:
Tokens, for its model (when it has one).
Affordances (
<aff>): tools the model calls, and callbacks the harness calls.Mounts: its children. A mount produces no tokens:
tsximport { Greeter } from "./greeter.tsx"; <Mount key={name} model="default" machine={harness.machines[i]}> <Greeter name={name} heard={greetings} onGreet={g => setGreetings([...greetings, g])}/> </Mount><Mount>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,modelandmachine(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
onXis 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:<key>: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 <Mount model> 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 <dir>/<parent>/<key>/. 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):
- settles what it answers and resumes the tasks waiting on it;
- runs the listeners of an event (
<aff event="name" onCallback>,mount:callbacks) at once, even while other tasks wait on effects; - renders, and runs the effects whose deps changed (
useEffect: cleanups, then setups), again while setups change state, at most 50 passes (else it stops withmax_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 <name>: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(<App .../>, 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<Response> | 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 <Mount>'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 <api>. 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:/generatewithinput_ids;completions:/v1/completionswith token-id prompts andreturn_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.
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. 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. 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
abortfinish, 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-<offset>.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 <dir>/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-<offset>.bin (uncommitted-store-<offset>.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
<dir>/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-<offset>.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 <id>/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 / <Mount> 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 |
|---|---|
<id>/agent.json | The instance's spec and pins. Secrets are removed. |
<id>/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). |
<id>/trace.jsonl | Renders, replies, model calls (usage, cost), semantic tool completions, handler errors, stops. Secrets redacted. |
<id>/snapshot.json | Diagnostic state and metrics at shutdown. |
<id>/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/<op>.json | {operation_id, targets, payload} delivers an operator message. It is journaled as an event with op, and acknowledged in control/receipts/<id>/<op>.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). 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
<Mount>. 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: spec/HARNESS.md, about 37 minutes to read.