Every page, agent‑operable.

Your UI already knows what it can do. Affordances lets you declare it once. Humans, browser agents, headless agents and MCP clients all use the same definition.

Read this page as
GET /products/court-runner-2Accept: text/html
Acme Store
Court Runner 2 sneaker in white with a gum sole, side view
White / Gum
Court Runner 2
$160.00
2 in cart
Size
10Size 11, sold out
Call the action. Get back only what changed.

Same URL, same session. Switch to Agent to read the page as JSON.

The shift

Every page has two readers now.

Human · text/html

Reads pixels.

Scrolls, hovers, decides. Your UI was built for this reader, and it shows.

Agent · application/vnd.affordances+json

Reads structure.

Needs names, inputs and preconditions. Today it gets a screenshot and a guess.

Before

Serving agents today means describing every page five more times.

A markdown twin. An OpenAPI spec. MCP routes. Tool handlers. WebMCP glue. Each one a hand-written copy of what the UI already knows.

Developer6 files · 1 route
app/products/[slug]/page.tsx$140
public/products/page.mdstill $160
public/openapi.jsonstill $160
app/api/mcp/route.tsstill $160
lib/agent/tools.tsstill $160
lib/agent/webmcp.tsstill $160
page.tsx · line 14
<Price amount={160} />
<Price amount={140} />
One edit. Five lies.5 of 5 files still say $160. Nothing fails. Agents just get it wrong.
Agent · screenshot loopstep 25 / 25
#22 screenshot()
#23 type(email)
#24 screenshot()
#25 screenshot() // did it work?
Context window exceededtask: add to cart, size 10
Context window exceeded
limit
screenshotsopenapi.jsonMCP tool listpage.md
Twenty-five screenshots.To add one pair of shoes to a cart.

After, with Affordances

Three files per route. One definition.

page.tsx renders. state.ts says what’s on the page. actions.ts says what can be done. Every agent surface is derived from those, so there’s nothing left to drift.

Developer3 files · 1 route
app/products/[slug]/page.tsxrenders
public/products/page.mdstill $160
public/openapi.jsonstill $160
app/api/mcp/route.tsstill $160
lib/agent/tools.tsstill $160
lib/agent/webmcp.tsstill $160
app/products/[slug]/state.tsprice: $140
app/products/[slug]/actions.ts5 surfaces
state.ts
stock: 12,price: ,new: true,
  • Server action$140
  • WebMCP$140
  • HTTP graph$140
  • MCP$140
  • Markdown$140
One edit. Every surface.Change the price in state. Humans and agents see $140 together.
Agent · Affordances graphcall 5 / 5
01GET/products/court-runner-2200 graph
02POSTaddToCart { size: 10 }200 delta
03GET/checkout200 graph
04POSTplaceOrder409 confirm
05POSTplaceOrder { confirm: true }200 delta
Context window, same scaletask: buy, size 10
Context window, same scale
limit
graphdeltaconfirm
Five calls.Read the graph, act, read the delta. No pixels involved.

How it works

Declare it next to the page.

An action is a server action with a schema, an exposure level and a list of what it changes. Pick a surface to see what the same definition becomes.

actions.ts
'use server'

export const addToCart = defineAction({
  name: 'addToCart',
  description: 'Add a variant of this product to the cart',
  input: z.object({
    sku: z.string(),
    quantity: z.coerce.number().int().min(1).default(1),
  }),
  state: product,
  expose: 'public',
  requires: 'The variant is in stock',
  guard: ({ input, state }) =>
    !input || inStock(state, input.sku) || 'Sold out',
  effects: ['cart'],
  run: (input, ctx) => cart.add(ctx, input),
})

The page’s own URL answers with a graph. A call returns a delta with only the entities in effects.

Terminal
curl -H 'Accept: application/vnd.affordances+json' \
  shop.example/products/tee
# "actions": [{ "name": "addToCart", "expose": "public",
#   "guard": "The variant is in stock", "available": true }]

curl -X POST shop.example/products/tee \
  -H 'Content-Type: application/vnd.affordances+json' \
  -d '{"action":"addToCart","input":{"sku":"TEE-BLK-M"}}'
# { "ok": true, "changed": { "cart": { "count": 1 } } }

Approvals

Agents act. Humans approve.

Mark an action expose: 'confirm' and an agent can prepare it but never finish it. The call waits for a person.

agent · MCP session
1export const placeOrder = defineAction({
2 name: 'placeOrder',
3 expose: 'confirm',
4 run: (input, ctx) => orders.place(ctx.session.id),
5})

Discovery

Agents find it on their own.

A browser agent that has never heard of Affordances, asked to add a tee to a cart. Its browser supports WebMCP, and its prompt says only that pages can register tools. On the product page, the page offers addToCart, and it calls it.

With WebMCP: it went to the home page, which offered a search tool, then to the Heavyweight Tee’s page. That page offered addToCart instead, and the agent called it with sku TEE-BLK-M and quantity 1. Done in 10.6 seconds, 4 turns.

Without WebMCP: it went to the home page, then the product page, clicked size M, clicked Add to cart, then went to the cart to check. Done in 20.8 seconds, 6 turns.

One recorded run on Claude Opus 5.5: the same task, both agents on one clock.

Proof

Measured, not claimed.

The same shopping tasks on the same model, against two baselines: a browser agent reading accessibility snapshots and a screenshot agent clicking on pixels. The Affordances agent reads the graph. From the latest committed runs; the bench page has every row.

Model
fewer tokens than the screenshot agent
8.4×
11.6× fewer than the browser agent
Affordances agent 4,093Browser agent 47,681Screenshot agent 34,551
faster than the browser agent
1.9×
2.3× faster than the screenshot agent
Affordances agent 8.9sBrowser agent 17.1sScreenshot agent 20.3s
cheaper per success than the screenshot agent
5.1×
5.3× cheaper than the browser agent
Affordances agent $0.019Browser agent $0.10Screenshot agent $0.097
tasks completed
100%
Affordances agent 100%Browser agent 100%Screenshot agent 100%

Every agent completed every task here. On a smaller model, not all of them do.

Per task

Tokens, median of 5 runs

Browser agentScreenshot agentAffordances agent
Metric
Per task medians on Claude Opus 5.5, 5 runs each
TaskBrowser agent tokensScreenshot agent tokensAffordances agent tokensBrowser agent timeScreenshot agent timeAffordances agent timeBrowser agent passedScreenshot agent passedAffordances agent passed
Add to cart47,68145,5154,09315.9s21.7s9.2s5 of 55 of 55 of 5
Change quantity40,18916,0864,34716.8s10.6s8.8s5 of 55 of 55 of 5
Checkout48,55534,0384,00520.3s17.4s8.9s5 of 55 of 55 of 5
Search10,82534,5512,7246.9s20.0s7.9s5 of 55 of 55 of 5
Order status9,14515,9822,18910.2s7.3s4.0s5 of 55 of 55 of 5
Two items checkout177,717106,84519,20821.2s37.6s15.6s5 of 55 of 55 of 5
Edit cart219,337107,19216,01324.6s47.3s15.5s5 of 55 of 55 of 5

Claude Opus 5.55 runs per task2026-10-29commit ff7ddb4

Principles

Three rules it doesn’t bend.

  1. Signal, not pixels.

    An agent reads names, inputs and preconditions from the graph. No screenshots, no guessing which button does what.

    "guard": "The variant is in stock"
    "available": true
  2. One source, no drift.

    Every surface reads the same definition, so one edit reaches all of them. There is no second copy to forget.

    actions.ts | 2 +-
    → form, WebMCP, HTTP, MCP
  3. Agents act, humans approve.

    Actions without expose stay invisible to agents. Mark one confirm, and an agent can prepare it but only a person can finish it.

    expose: 'confirm'
    ← 409 confirm_required

For agents

Reading this as an agent?

Skip the pixels. Every page here is also Markdown and a graph, at the same URL.

$ curl -H 'Accept: application/vnd.affordances+json' https://affordances.vercel.app