The agent runtime
Contract authority: public contracts, language and harness.
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; ownership: 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.
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.yamlbeside 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_turnsfor a list element; VALUE parsed as YAML);--dirand--app. Maps merge key by key, lists and scalars replace,nullremoves 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 withkey_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 configprints the resolved deployment (same arguments asrun), with the values ofsecretshidden.
#Writing an app
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 = <>
<box label="status">{`${harness.agent} | notes: ${notes}\n`}</box>
<Tool fn={shell}>{"[shell]"}</Tool>
<Tool fn={set_notes}>{" [set_notes]\n"}</Tool>
<aff event="message" onCallback={async ({ from, text }) => {
log.append({kind: "input", content: `[message from ${from}] ${text}`});
}}/>
</>;
return <ModelContext strategy="unrolled" log={[{kind: "input", content: task}, ...log.records]}>{app}</ModelContext>;
}Prefer imported tool functions through <Tool fn={...}>. 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: <Tool fn={lookup}/> (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, <aff> 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: <message>. |
| 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 <aff event="message" onCallback={...}/>, 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 ameasurerender).
#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.
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
<name>: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 </think> 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<Response> | The bound http executor, with standard response readers. See Fetch and MCP 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(<App .../>, 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. |
{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:
- mask long tool outputs (level 1), then drop reasoning and old screens (level 2);
- then summarize:
model(<SessionContext/>), 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
execchild gets onlyPATH,HOME,LANGand 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
secretsare redacted ([secret:KEY]) intrace.jsonlandagent.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.jsonlstores 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.tsxmounts 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:libraryin the deployment; else the library checkout the app lives in (the nearest directory above it holdingindex.json, or a checkout'sviews/); else the copy bundled withaffordance. Any other import is a load error.One
Encoderper 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
waitor a message costs no model calls.DP-rank pinning. Model calls are pinned to a data-parallel rank (
rankper agent, or a stable hash), so each agent's prefix stays in one rank's cache.Model servers (
model, and more by name inmodels):sglang:/generatewithinput_ids,stop_token_ids,no_stop_trim,routed_dp_rank;completions:/v1/completionswith a token-id prompt andreturn_token_ids;chat: an OpenAI-compatible/chat/completions(url,model, a key fromkey_env(an environment variable) orkey_file+key_name(a file'sNAME=valueline), none for a local server, optional non-secretheaders); the app renders throughMessagesApp. With notokenizer, 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 inspec/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 afterstall_s(default 5400 s). Model configurations are strict, includingprovider_optionsand per-binding bearer credentials. Native LoRA selection usesprovider_options.lora_path; it is not sampling. See the configuration contract and multi-binding examples, sessions and recovery.Files in the journal directory (
dir):<dir>/<agent>/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); <dir>/blobs/<sha256>: large strings (below);<dir>/commit.jsonl: the journals' commits (below);<dir>/runtime.lock: locked by the one runtime that owns the directory. A second runtime over it (--resumeor a fresh run) fails before it recovers anything; a crash releases it.
Large strings live on disk. With
dir, a string of at leastspill_bytes(default 64 KiB) is written once to<dir>/blobs/<sha256>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.execwrites an output past that size straight to a blob file as it arrives. Machine calls and reports get the text back;sendandstore.*pass references through. A 16 MiB tool output costs about a hundred bytes of heap wherever the app keeps it. Withoutdir, 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, andobserved.json. Aharness.sendto another instance, timers, the instance tree andstore.*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-<offset>.binoruncommitted-store-<offset>.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.
--resumerebuilds 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.
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: "[email protected].{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<Encoder>, Arc<dyn Model>) (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 itssignalstops 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 eventuser({text}) to the first instance;{"type": "abort"}posts the eventabort;{"type": "history"}posts the eventhistory;{"type": "event", "name", "payload"}posts any event;{"type": "answer", "request", "value"}answers a question;{"type": "shutdown"}, or the end of stdin, ends the run (--resumecontinues it). - Out (stdout):
ready {agent};delta {agent, task, text, reasoning?}(ordelta {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'sgeneratestatistics such asfirst_token_msandtokens_per_s, finished tools, handler errors, the app'sconsolelines);ui {agent, value}andquestion {agent, request, ...};error {message};exit {error?}. - The machine
uiis the frontend. A call withask({ask: {kind: "permission", ...}}) becomes aquestion, and waits for the frontend'sanswer, whose value it returns; any other call is shown asuiand 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:
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:
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
fetchis cancelled as below and rejects withAbortError. - 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
execsends SIGTERM to the command's whole process group (led bytimeout), 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
sshclient; the remote command runs on until its remotetimeoutfires.
- A local
- 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.observersees 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 anAbortSignal); 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
generateentry: 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).
- a background-process table (
- 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 inlog.heldand 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/RUNTIME.md, about 27 minutes to read.