affordance

Showcase  /  Example

A first agent in 23 lines, and a recording of it at work

An agent is a model that uses tools in a loop until its task is done. The command affordance init writes the smallest useful one: an agent that can run shell commands and then give an answer. This page walks through that file and shows a real run of it.

The run

We gave the agent a task: count the lines in a file. Below is everything that happened, taken from the run's record. The grey italic text is the model thinking. Each blue label is a tool the model chose to call, and the green text is what came back.

affordance run
The task, from affordance.yaml
How many lines are in ./app.tsx? Count them with the shell, then answer.
Turn 1. The model read 384 tokens and wrote 65 in 276 ms.
The user wants me to count lines in ./app.tsx with the shell. Let me do that.
shellwc -l ./app.tsx
23 ./app.tsx
Turn 2. The model read 451 tokens and wrote 111 in 426 ms.
The file has 23 lines. But wc -l counts newlines; if the file lacks a trailing newline, the actual line count could be 24. Let's verify.
shellcd . && tail -c 1 ./app.tsx | xxd | tail -1; awk 'END{print NR" lines by awk"}' ./app.tsx
00000000: 0a                                       .
23 lines by awk
Turn 3. The model read 555 tokens and wrote 74 in 349 ms.
answer./app.tsx has 23 lines. `wc -l` reports 23, and since the file ends with a newline, awk's record count also confirms 23 lines.

A recorded run against a DeepSeek-V4.1-Flash variant served by vLLM: 3 model calls and 1.1 seconds of generation.

The model ran one command, was not sure it could trust the count, ran a second command to check, and then answered. Nobody wrote that plan. The loop gave the model the tools and kept asking until it called answer.

The file

This is the whole agent. The numbered marks are explained under it.

// 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>;
}
  1. 1The log. A list of everything so far: the task, each reply from the model, and the result of each tool.
  2. 2The loop. It sends the model the page below, runs whichever tool the model calls, adds the result to the log, and repeats.
  3. 3A tool. A tool is a function. The model is told its name, the comment above it and the type of its argument. This one ends the run.
  4. 4The page. What the model reads on each step: the task, the log and the two tools. ModelContext writes it in the format of whichever model you use.

Try it

$ cargo install affordance $ affordance init my-agent && cd my-agent $ affordance run

You need a model for it to talk to. A model running on your own machine with llama.cpp, Ollama, vLLM or SGLang works, and so does a hosted API. You set that in affordance.yaml, next to the app. Getting started has the details.

Everything the agent hears back is written to a file as it goes. If you stop the run, affordance run --resume carries on from where it stopped.