Docs / Running agents

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.

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.

#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 = <>
    <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:

WhatHow
The screenThe app's render: the current turn of a template app (TurnStrategy's final user turn).
ToolsFunctions: <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 resultsThe 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>.
StateuseState hooks, persisted and replayed by the runtime. Handlers change state only through setters.
HistoryApp 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 modelThe 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.
EffectsIn 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).
EventsuseEvent("message", listener) (or <aff event="message" onCallback={...}/>, zero tokens). A listener runs when its event arrives, even while tools wait.
Context windowLayout'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 repliesThe 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).
LanguageTypeScript/JSX with JavaScript semantics (spec/LANGUAGE.md §7), in apps and template adapters alike.

harness in a 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.

The runtime keeps no conversation. useLog() keeps one in app state, writing records from model()'s output and dispatch()'s results:

RecordFieldsWhen
screenids (the exact screen tokens), images?, hosted content?The model replied to it (output.screen).
modelids (exact generated ids, stop token included), reasoning, content, calls, problems, cut_off, provider_state?Each reply.
actionname, tool_call_id, result, plus the app's meta keys, plus error: truedispatch ran the call, or it was invalid (unknown tool or bad arguments, answered with an error result the model sees). In call order.
inputcontentlog.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)ResultServed 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 JSONThe 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.

KindConfigRuns
local—bash on this host, with only the agent's environ.
sshtarget, key?, control_dir?, known_hosts?bash on a VM over a shared SSH connection.
dockerimage, name? (default aff-{agent})bash in a container per agent, started on first use and kept.
bwraphome, network? (default false)bash in a bubblewrap sandbox: the host read-only, home writable, private /tmp.
commandargvAn external executor: JSON requests on stdin, JSON responses on stdout.
httpurl, headers?, timeout_ms?, byte limitsHTTP 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:

  1. mask long tool outputs (level 1), then drop reasoning and old screens (level 2);
  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 violations0
exact appends285 of 299 (the other 14: 12 folds and 2 branch returns)
fold landings17.9–19.9K, plus 1 summarized at 7.7K
max prompt47,972
prefix reuse94.5%
recall from folded history3/3 questions
read_eventbyte-exact, 2/2
journal replayidentical 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.

#Running many agents

affordance run, or Runtime from Rust. The contract is spec/HARNESS.md; this section is how to run it.

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: "[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.

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

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());

The rules are in spec/HARNESS.md §3. Under them, every model and machine call carries a host cancel token (runtime::CancelToken):

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):

#Limits

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