Docs / Embedding and internals

Harness guide: driving an affordance app from Rust

Contract authority: public contracts, language and harness.

For a complete first render/action/rerender without a model server, start with the walkthrough and hello.rs.

For harness authors who render an agent app to token ids, send them to a token-id endpoint (SGLang /generate with input_ids, vLLM /v1/completions with a token prompt), and feed the reply back. The worked example is views/demo/consumer/ (one app, three template entries) and tests/harness_consumer.rs, which runs every snippet below.

#1. Load once, render from many threads

Encoder is Send + Sync (a compile-time assertion in src/emit.rs keeps it that way). Load the tokenizer once per process and share it. View holds Rc values and is !Send, so each render thread loads its own View, bound to the shared encoder:

rust
let enc = Arc::new(Encoder::from_file("ref/tokenizers/zai-org__GLM-5.3.json")?);
for _ in 0..workers {
    let enc = Arc::clone(&enc);
    std::thread::spawn(move || {
        let view = View::load(Path::new("views/demo/consumer/glm53.tsx"), "Main", &enc).unwrap();
        let mut cache = RunCache::new(true);           // per thread, keep it across renders
        loop { /* view.render(&enc, state, &mut cache, now) */ }
    });
}

GLM-5.3, a 29.5K-token session, 20 renders per thread (tools/bench/render_threads.rs):

setup1 thread16 threads
Encoder + View per thread200 MiB3,099 MiB
one shared Encoder, View per thread203 MiB240 MiB (1.18×)

The encoder accounts for about 196 MiB. Each extra thread adds about 2.5 MiB (its View, RunCache and allocator arena). Warm render p50 is 2.7–3.0 ms in both setups.

#2. Tool names: tool_name

A model-callable action is an <aff> with an on* handler. When asChatTools (inside TurnStrategy) projects it to a function schema, the name defaults to verb__label: onRun on label="shell" becomes run__shell. Set the name explicitly with tool_name:

tsx
<aff label="shell" tool_name="shell" description="Run a shell command in your VM."
     args_schema={{type: "object", properties: {command: {type: "string"}}, required: ["command"]}}
     onRun={async ({ command }) => { ... }}>{"[shell]"}</aff>

Other props: description, and args_schema (a JSON schema; otherwise the schema comes from the destructured parameters). Duplicate names get an __h<n> suffix. The rendered affordance span has label == tool_name, so View::act_effects(.., "shell", 0, args, ..) dispatches the model's call by the name the model used.

#3. Strings and fragments

Written asValue
"a" + b, `a${b}` (plain template literal), String(x), JSON.stringify(x)string
p`a${b}` (tagged prompt literal), JSX (<>...</>, <box>), tok.x + "..."fragment: text, special tokens and laid-out ids

A fragment keeps token boundaries and special tokens; a string is plain text and is tokenized where it is emitted. Read-only string methods (startsWith, includes, indexOf, ...) and trim work on fragments. Indexing (c[0]) is an error for a general fragment and names the fragment (see section 7). An exact token-run fragment supports numeric indexing of its token IDs. A fragment joins only with text: adding an object or a number to it is a TypeError, so convert first.

In a TurnStrategy log:

Use a plain string when you only need text. For exact token round-trips, log ids instead.

#4. TurnStrategy log records

<TurnStrategy strategy="unrolled" | "last-turn" log={log}>{app}</TurnStrategy> turns the log into chat turns. The current screen (the app) is the final user turn; an app with nothing on its screen (a plain conversation) adds none, and the prompt ends with the log's last turn.

kindturnfields read
inputusercontent or ids
screenuser (unrolled only; skipped by last-turn)content or ids
modelassistantcontent, reasoning → reasoning_content, calls → tool_calls; or ids
actiontoolresult (text, or a list of content parts: {type: "text", text}, {type: "image_url", image_url: {url}}) or ids, plus name and tool_call_id
metric, eventnone (kept in the log, skipped)none

Any other kind is an error naming the record (TurnStrategy log[7]: unsupported log record kind "note"). Every other field (id, seq, pinned, exit, timings, effect transcripts) is ignored, so you can pass your full event log. Records are plain JSON. Inside the view you can filter and rewrite the log before passing it in (the consumer's selectLog).

#5. Exact ids: ids on records, and the current screen span

Exact-append prefix caching needs the next prompt to start with previous prompt ++ generated ids. Text records cannot guarantee that:

Log ids instead.

Screens. Rendered::current_turn() is the token range of the current screen's body (span label turn:current, without the template's role tokens). current_turn_ids() returns its ids:

rust
let r = view.render(&enc, state, &mut cache, now)?;
log.push(json!({"kind": "screen", "ids": r.current_turn_ids().unwrap()}));

Model turns. Log the generated ids exactly as the server returned them, including the stop token:

rust
log.push(json!({"kind": "model", "ids": output_ids, "reasoning": parsed_reasoning, "calls": parsed_calls}));

The template app renders the turn as its generation prompt, the prefix add_generation_prompt appended to the prompt the model saw (GLM-5.3: <|assistant|><think>; Qwen3.6: <|im_start|>assistant\n<think>\n), followed by ids verbatim:

Action results and inputs may carry ids too, for example to replay a tool result exactly.

In a template, a generated turn's m.ids is a token run, the same fragment as its content (4 bytes an id, not a list value per id). It reads like a list of ids: .length, [i], .at(-1), .slice(0, -1) (another token run), .includes(id), spreading and for … of. tokens(m.ids) passes it through.

Images. An <img src t tok_size> in a model's prompt lays out its placeholder tokens, and the model request carries its src: SGLang's image_data, one per <img>, in prompt order. A template renders an image content part that has a source as such an <img> (DeepSeek V4.1: one <|deepseek_image|>, which the server expands, so the ids stay exact and the turn stays an exact append). A tool returns an image as content parts; its record keeps them. A screen record keeps its <img>s as images: [{at, n, src}]. A source is the image's bytes: a data:image/(png|jpeg|webp|gif);base64, URL, or base64 PNG, JPEG, WebP or GIF bytes (sent as a data URL). A URL or a path, which the model server or provider would fetch or open for the agent, fails the call before it is sent (CSP img-src data:); a template still renders it (the ids stay exact). A binding that cannot send images (completions) refuses such a request. Requests with images skip sessions. SGLang's prefix cache stops at the first image, so an app that wants long contexts cached shows an image for a turn and then renders a text stand-in for it (app policy, like folding).

Reducing a record (folding): ids take precedence over the text fields, so a view that rewrites old records must drop them. {...e, reasoning: "", ids: null} renders from the fields again.

tests/harness_consumer.rs checks the invariant for GLM-5.3, Qwen3.6 and Qwen3-2507. With ids, prev_prompt ++ generated is a prefix of the next render on every turn of a 10-turn session. That session includes whitespace before </think>, non-canonical generated ids, text-only replies, an injected input and adjacent <aff> labels. The same session logged as text breaks the prefix in every family.

#5a. Incremental renders: memoScan

An agent re-renders its whole history every turn. With memoScan, a pass over the transcript does the work of what is new:

js
const r = memoScan(items, init, (state, item, index) => [nextState, out], deps);
// r.state: the final state; r.outs: [out, ...], one per item

Lookahead becomes state. A message whose output depends on the next message is written when the next one arrives (DeepSeek V4.1's generation prompt after a user turn), or a block is held in the state until it ends (GLM-5.3's reordered tool results). The DeepSeek V4.1 and GLM-4.5..5.3 views and views/agents/session.tsx are written this way. TurnStrategy keeps each record's turn, by record.

#6. Effects: the harness API for external actions

The runtime (spec/HARNESS.md, docs/RUNTIME.md) runs this loop for you: model calls, exact-id records, messages, waits, machines, resume. Use what follows only for a custom host.

A handler that needs the outside world awaits a harness request. The host serves requests by API name (machine:<role>, send, store.*), so it never matches tool names:

tsx
<aff label="shell" tool_name="shell" ... onRun={async ({ command }) => {
  const r = await harness.machine.call({cmd: command});
  return {result: `${r.output}\n[exit ${r.exit}]`};
}}>{"[shell]"}</aff>
<aff label="message" tool_name="send_message" ... onSend={async ({ to, text }) => {
  const r = await harness.send(to, {text: text});
  return {result: `delivered to ${to}`};
}}>{"[send_message]"}</aff>

Host loop for one tool call (name, args parsed from the reply):

  1. let step = view.act_effects(&enc, state, now, name, 0, &args, &harness, transcript)?; This renders state, finds the handler by tool name, and drains its queued work until it needs external input. step.effects is the transcript (Request::Call { app, args }, where app is machine: (the default machine), machine:<role>, send, store.put, ..., plus an outcome once served) and step.blocked lists the pending indices.
  2. Serve each blocked request, on any thread or task. Nothing is held between steps.
  3. When a result arrives, set transcript[i].outcome = Some(Outcome::Ok(value)) (or Outcome::Error(msg)). Set transcript[i].settled to an increasing arrival counter, then call act_effects again. It reconstructs one execution and delivers those outcomes in arrival order through the same queue as the live Agent, either parking or finishing (done == true).
  4. When done becomes true, step.value is the handler's return value: the tool result. Continue serving blocked requests if it started unawaited work. Append {"kind": "action", "name": name, "result": ...}.

Promise.all([...]) issues several requests at once (spec §7.2). A changed request on replay is a divergence error. Missing settlement counters use input order. Use runtime::Agent for long-lived execution: it retains frames and only outstanding requests between inputs. The standalone transcript API retains no execution between calls; PreparedHandler::run_live has been removed.

tests/harness_consumer.rs::harness_api_effects_with_a_slow_external_call runs this for two agents. A machine command sleeps 300 ms on a worker thread while a send completes at once. The send is committed first, the command's result later, and the next render shows each result as a tool turn.

#7. Errors

Render errors keep the message as the first line, then add the view stack, innermost first, and the log record being rendered:

a fragment cannot be indexed: this slot expects a string or a list, got a fragment (did you use p`...`? use a plain `...` string, or a template that accepts fragments)
    at first_output (/tmp/t.tsx:4)
    at ToolTurn (/tmp/t.tsx:7)
    while rendering TurnStrategy log[2] (kind "action")

affordance::error_message(&err) returns the first part only. affordance check compares messages with it. Malformed records fail in TurnStrategy with their index. Examples: a missing kind, ids that are not token ids, a map as content/result, or exact model ids sent to a template app that does not support them.

#Rendering chat requests in a model server

A model server that applies a Jinja chat template and then tokenizes can call the engine instead: affordance::chat_render::ChatRenderer takes a request's messages, tools and template arguments and returns the prompt's token ids, through the model's template view.

rust
use affordance::chat_render::{ChatRenderer, Options};
let renderer = ChatRenderer::json(Options::new("views/qwen/qwen35.tsx", "Qwen36", "tokenizer.json"))?;
let ids = renderer.render(&messages, tools.as_ref(), &serde_json::json!({"add_generation_prompt": true}))?;

It keeps what it rendered, by conversation, so a request that extends an earlier one costs what is new; it owns its rendering threads and shares one loaded tokenizer. A server with its own message type passes a function from a message to the JSON a template reads (ChatRenderer::new), which runs only for messages it has not seen. A request the official template would reject comes back as RenderError::Raised with the template's message.

Source: docs/HARNESS_GUIDE.md, about 11 minutes to read.