Protocol
The normative Affordances protocol, version 0. This page renders packages/protocol/spec.md from the repository at build time.
Affordances lets an agent read and operate a web page through the same URL a person uses, with the same session. A page answers in HTML for browsers and in JSON for agents. The JSON lists what the page shows (entities), what can be done there (actions), and where to go next (nav). Calling an action returns a delta: the parts of the page that changed.
This document is normative. The key words "must", "must not", "should", and "may" are used as in RFC 2119. The JSON Schemas in schemas/ are generated from the same source as the TypeScript types and are authoritative for shapes.
Media type
All Affordances documents use application/vnd.affordances+json. A server must not return an Affordances document for a request that did not ask for one.
Discovery
Per page. A client requests the page's own URL with Accept: application/vnd.affordances+json. The server responds with a graph for that page, or a not_found delta with status 404 if the page has no Affordances graph.
HTML responses for pages with a graph should advertise it in both places:
Link: <?affordances=1>; rel="affordances"<link rel="affordances" href="?affordances=1" />?affordances=1 is an alias for the Accept header, for clients that cannot set headers. A server that supports it must treat GET <url>?affordances=1 like GET <url> with the Affordances Accept header, and must leave affordances out of the searchParams it gives the page's state.
Per site. GET /.well-known/affordances.json returns a site index: the protocol version, the route patterns that have graphs and their action names, the supported auth modes, and optionally an MCP endpoint.
A shared cache must never serve one representation for the other: graphs send Vary: Accept and Cache-Control: private, no-store, and a server must make sure cached HTML is keyed on Accept too (with Vary: Accept, or by routing Affordances requests before the cache, as @affordances/next does with rewrites).
Graph
type Graph = {
v: '0'
page: string // concrete path: /products/heavyweight-tee
route: string // pattern: /products/[slug]
entities: Record<string, unknown>
actions: ActionDescriptor[]
nav?: { to: string; label?: string; when?: string }[]
md?: string
}entitiesis the page's state, keyed by name. It must not contain anything the page would not show the same user in HTML.actionslists every action on the route that the requester may know about. Actions the requester can't call right now are still listed, withavailable: falseand areason.navlists links an agent can follow.whenis a human-readable condition.mdis an optional Markdown rendering of the page's content.- A graph should stay small. About 2,000 tokens is a good ceiling, and large collections should be summarized in
entities.
Action descriptor
type ActionDescriptor = {
name: string // unique per route, stable across releases
description: string
input: JSONSchema
output?: JSONSchema
expose: 'public' | 'session' | 'confirm'
guard?: string // human-readable precondition
available: boolean // the guard, evaluated for this request
reason?: string // why available is false
effects?: string[] // entity keys the action changes
readOnly?: boolean
}expose says who may call an action:
expose | Caller |
|---|---|
public | anyone |
session | an authenticated session, by cookie or bearer token |
confirm | a session, and the call must carry confirm: true after the user has seen a summary |
An action with no expose is not part of the protocol. It must not appear in graphs and must not be callable.
Call
POST <page-url>
Content-Type: application/vnd.affordances+json
{ "action": "addToCart", "input": { "sku": "TEE-BLK-M" } }type Call = { action: string; input?: unknown; confirm?: boolean }The server processes a call in this order, and stops at the first failure:
- Resolve the action by name on the page's route. Failure:
not_found. - Check the CSRF rule below and
exposeagainst the caller. Failure:unauthorized. - Apply the rate limit. Failure:
rate_limited. - Validate
inputagainst the action's input schema. Failure:invalid_input, withissues. - Evaluate the guard. Failure:
unavailable, with the guard's reason asmessage. - For
confirmactions withoutconfirm: true:confirm_required, with a summary of what will happen asmessage, written for the person who approves it. Nothing runs. - Run the action, reload the page's state, and respond with a delta.
Delta
type Delta = {
ok: boolean
changed?: Record<string, unknown>
output?: unknown
actionsAdded?: ActionDescriptor[]
actionsRemoved?: string[]
redirect?: string
message?: string
error?: { code: ErrorCode; message: string; issues?: unknown }
}changedholds the new value of each entity in the action'seffects. When an action declares no effects,changedholds every top-level entity whose value differs after the action ran.outputis the action's return value, described by the descriptor'soutputschema when it has one.actionsAddedandactionsRemoveddescribe changes to the action list, including availability changes. An action whoseavailableflipped appears inactionsAddedwith its new descriptor.redirectis a path the person would have been sent to. The agent should request it next.messageis a short sentence suitable to repeat to the user.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
not_found | 404 | No such route or action |
invalid_input | 422 | input failed the schema; issues says where |
unauthorized | 401 | The caller may not call this action |
confirm_required | 409 | Resend with confirm: true after showing message |
unavailable | 409 | The guard failed; message says why |
rate_limited | 429 | Too many calls; the response should carry Retry-After |
internal | 500 | The action threw; message must not leak internals |
Site index
type SiteIndex = {
v: '0'
name?: string
routes: { route: string; description?: string; actions: ActionSummary[] }[]
auth: ('cookie' | 'bearer')[]
mcp?: string
}
// an action descriptor without this request's availability (no `available`, no `reason`)
type ActionSummary = Omit<ActionDescriptor, 'available' | 'reason'>The index is enough to plan a task and to call an action without opening its page first: an agent that knows the route and the input can POST straight to a concrete URL of that route. It still gets unavailable or unauthorized if the guard or expose says no. Each route's description is one line on what the page is for. Input schemas may omit $schema; they are JSON Schema 2020-12.
Security
- Default deny. Only actions that declare
exposeare reachable. - A call that authenticates by cookie must carry an
Originheader that matches the site. A call withAuthorization: Beareris exempt. A server must not rely on its framework's server-action protections for Affordances calls. confirmactions must not run withoutconfirm: true. Clients acting for a person should showmessageand get approval first.- Input is validated before the guard runs, and the guard runs before the action.
- URLs must not carry personal data. A graph must not include data that the HTML page would not show to the same user.
Versioning
v is the protocol's major version. A breaking change ships as a new major version. Additive changes, such as new optional fields, may ship within a major version, and clients must ignore fields they don't recognize. Packages declare the range of majors they support.