Guide
Build an app.tsx
An Affordance app is a TSX file that renders state into the tokens a model reads. This page builds one in six steps, from a line of text to an agent that runs. Each output is what the command printed.
An app is a function from state to tokens
The default export is a function. It takes the state as its props and returns what the model should read. affordance render prints the token ids, the text they decode to, and any spans.
export default function Hello({ name }) {
return <>{`Hello, ${name}!\n`}</>;
}ids [15496, 11, 995, 0, 198] text Hello, world!
With no --tokenizer, render uses GPT-2's. Pass a model's tokenizer.json and the ids are that model's.
The language is a subset of TypeScript and JSX, interpreted in Rust. It reads like React and runs without React, Node or npm. An unknown lowercase tag is an error.
State comes in as JSON
Anything in the state can be mapped into text. Strings render as text, arrays render each item in order, and null and false render nothing, as in React.
export default function Inbox({ mail }) {
return <>
{`Inbox: ${mail.length} unread\n`}
{mail.map(m => `${m.from}: ${m.subject}\n`)}
</>;
}{ "mail": [
{"from": "Ada", "subject": "The build is red"},
{"from": "Grace", "subject": "Lunch on Friday?"},
{"from": "Linus", "subject": "Re: patch v3"}
] }ids [818, 3524, 25, 513, 555, 961, 198, 2782, 64, 25, 383, 1382, 318, 2266, 198, 8642, 558, 25, 40514, 319, 3217, 30, 198, 14993, 385, 25, 797, 25, 8529, 410, 18, 198] text Inbox: 3 unread Ada: The build is red Grace: Lunch on Friday? Linus: Re: patch v3
A tool is a function the model can call
A web page has buttons. An app has tools. <Tool fn={archive}> offers the function to the model and marks the tokens it is drawn with. The model reads the function's name, its comment and its argument type.
import { Tool } from "@affordance/lib/tool.tsx";
export default function Inbox({ mail }) {
const [unread, setUnread] = useState(mail);
/** Archive every message from one sender. */
function archive({ from }: { from: string }) {
setUnread(unread.filter(m => m.from !== from));
return `Archived the mail from ${from}.`;
}
return <>
{`Inbox: ${unread.length} unread\n`}
{unread.map(m => `${m.from}: ${m.subject}\n`)}
<Tool fn={archive}>[archive]</Tool> </>;
}ids [818, 3524, 25, 513, 555, 961, 198, 2782, 64, 25, 383, 1382, 318, 2266, 198, 8642, 558, 25, 40514, 319, 3217, 30, 198, 14993, 385, 25, 797, 25, 8529, 410, 18, 198, 58, 17474, 60] text Inbox: 3 unread Ada: The build is red Grace: Lunch on Friday? Linus: Re: patch v3 [archive] span 32..35 affordance span 32..35 action run(from)
{
"name": "archive",
"description": "Archive every message from one sender.",
"parameters": {
"type": "object",
"properties": {
"from": {
"type": "string"
}
},
"required": [
"from"
]
}
}useState keeps a value between renders, as in React. When the model calls archive with from: "Ada", the handler runs and sets the state. Its return value is the result the model reads. Then the app renders again:
ids [818, 3524, 25, 362, 555, 961, 198, 8642, 558, 25, 40514, 319, 3217, 30, 198, 14993, 385, 25, 797, 25, 8529, 410, 18, 198, 58, 17474, 60] text Inbox: 2 unread Grace: Lunch on Friday? Linus: Re: patch v3 [archive] span 24..27 affordance span 24..27 action run(from)
This output is affordance render on the state the handler leaves. A running agent does the same render after each call.
A tool exists only where it is rendered. If a render leaves the <Tool> line out, the model cannot call it on that turn. Underneath, <Tool> is the element <aff>, an action attached to tokens.
A style sheet sets budgets in tokens
CSS can size a box in pixels because the browser lays the page out. Affordance lays the prompt out in tokens, so a token style sheet can size a box in tokens. Selectors name components and elements, as in CSS.
Output {
max-tokens: 90tok;
overflow: clip-middle;
more: "\n[... {hidden} tokens hidden ...]\n";
}import "./shell.tss";
const Output = ({ children }) => children;
export default function Shell({ command, output }) {
return <>
{`$ ${command}\n`}
<Output>{output}</Output>
</>;
}$ cargo test --release --lib
running 66 tests
test emit::drop_cut_tests::graded_results_grow_in_steps ... ok
test grammar::tests::byte_level_map_is_a_bijection ... ok
test
[... 1355 tokens hidden ...]
ok
test result: ok. 66 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.21sThe two units are tok, a count of tokens, and win, a percentage of the model's context window. The rule most agents want is for the history:
history {
max-tokens: 75win; /* at most 75% of the window */
overflow: drop-start; /* drop whole turns, oldest first */
drop-step: 25%; /* a quarter at a time, so the server's cache survives */
}The drop-step story has what that last line measured.
A model format and a loop make it an agent
So far the app renders plain text. A model wants its own chat format: its turn markers, its tool definitions, its reasoning tags. ModelContext wraps the app in the format of the model you name, and useLoop calls the model and runs the tools it picks. affordance init writes this file:
// A first agent: it reads its task, may run shell commands, and stops when it answers.
// The page is the task, the tools and the log of what the model saw and did; ModelContext
// renders it in the bound model's format (a hosted chat API, or a native token-id server).
import { ModelContext } from "@affordance/lib/model.tsx";
import { useLog } from "@affordance/lib/log.tsx";
import { useLoop } from "@affordance/lib/loop.tsx";
import { Tool } from "@affordance/lib/tool.tsx";
import { machineTools } from "@affordance/tools/machine.tsx";
export default function Main({ task }) {
const log = useLog(); 1
useLoop(log); // ask the model, run its calls, repeat 2
const { shell } = machineTools(harness.machine.get("home"));
/** Give your final answer. This ends the task. */
function answer({ text }: { text: string }) { 3
harness.stop({ answer: text });
return "done";
}
return <ModelContext strategy="unrolled" log={[{ kind: "input", content: task }, ...log.records]}> 4
<Tool fn={shell}>{"[shell] "}</Tool>
<Tool fn={answer}>{"[answer]\n"}</Tool>
</ModelContext>;
}- 1The log is the history: the task, each reply from the model and each tool result. It is app state.
- 2The loop is a library hook of 18 lines. It asks the model, runs the calls in its reply, and asks again.
- 3A tool, as in step 3. This one stops the agent.
- 4
ModelContextrenders the task, the tools and the log in the model's format.
app:
view: app.tsx
model:
kind: chat
url: http://127.0.0.1:8080/v1
model: local
context: 8192 # the model's context length
machines:
home:
kind: local # this machine's shell
agents:
- id: first
max_turns: 8
props:
task: How many lines are in ./app.tsx? Count them with the shell, then answer.23 ./app.tsx
00000000: 0a . 23 lines by awk
affordance.yaml says where the app runs: which model server, which machines its tools may use, and the task. The app does not change when those do.
A recorded run against a DeepSeek-V4.1-Flash variant served by vLLM: 3 model calls and 1.1 seconds of generation.
harness is to an app what window is to a page
A page reaches the world through window: it fetches, sets timers and posts messages. An app reaches the world through harness and a few globals. In step 5, harness.machine.get("home") is the shell and harness.stop ends the run.
| On a web page | In an app | What it does |
|---|---|---|
| fetch(url) | fetch(url) | An HTTP request, through an origin the deployment allows. |
| a request to a server | model(), dispatch(output) | model() sends this app's render to its model. dispatch runs the tool calls in the reply. |
| addEventListener | useEvent(name, fn) | Runs a listener when an event arrives, such as a user's message. |
| setTimeout | harness.wait({ ms, events }) | Parks the task until a time or an event. |
| postMessage | harness.send(to, payload) | Sends an event to another agent. |
| localStorage | harness.store.put / get / search | Storage that belongs to this agent. |
| ResizeObserver | useEvent("layout", fn) | Reports how many tokens each box took and hid, after each prompt is laid out. |
| Date.now(), Math.random() | harness.now(), harness.random() | Recorded in the journal, so a replay sees the same values. |
| harness.machine.call({ cmd }) | Runs a command on a machine the deployment names: this host, a container, or a host over SSH. | |
| window.close() | harness.stop(value) | Stops the agent with a result. |
A render is pure: it reads state and returns tokens. Calls to the world happen in handlers and effects. Each result is written to the agent's journal, so a stopped run can continue and a finished run can be replayed turn by turn.
The model is never called for you. The loop in step 5 is 18 lines of TSX in the library, and you can copy it and change when your app asks the model.