Quickstart
Give one page of a Next.js 16 app a graph and an action, then read and call it with curl.
You need a Next.js 16 app using the App Router, and zod 4 (or any schema library that implements Standard Schema and Standard JSON Schema).
Install and initialize
vlt install @affordances/next @affordances/react zod vlx @affordances/cli initnpm install @affordances/next @affordances/react zod npx @affordances/cli initpnpm add @affordances/next @affordances/react zod pnpm dlx @affordances/cli inityarn add @affordances/next @affordances/react zod yarn dlx @affordances/cli initbun add @affordances/next @affordances/react zod bunx @affordances/cli initdeno add npm:@affordances/next npm:@affordances/react npm:zod deno run -A npm:@affordances/cli init
init does four things, and skips any that are already done:
- writes the route handler at
app/api/affordances/graph/[[...path]]/route.ts(or undersrc/app); - wraps the default export of
next.config.tswithwithAffordances; - adds
.affordancesto.gitignore; - generates
.affordances/manifest.tsfrom youractions.tsandstate.tsfiles.
The route handler it writes is one line:
export { GET, POST } from '@affordances/next/route'Wrap the config
If your config's default export isn't a plain identifier, init asks you to wrap it yourself:
import type { NextConfig } from 'next'
import { withAffordances } from '@affordances/next/config'
const nextConfig: NextConfig = {}
export default withAffordances(nextConfig)withAffordances regenerates the manifest when Next loads the config, and again in next dev whenever an actions or state file changes. It also adds the rewrites that send agent requests for a page to the route handler. Your pages and their HTML are untouched.
Describe the page
Affordances reads two files next to a page. state.ts returns what the page shows:
import { defineState } from '@affordances/next'
import { entries } from '@/lib/db'
export default defineState(async () => ({
entries: await entries.list(),
}))actions.ts is a regular server actions file. Each defineAction export is a server action your components can call, and, because it sets expose, an action agents can call too:
'use server'
import { refresh } from 'next/cache'
import { z } from 'zod'
import { defineAction } from '@affordances/next'
import { entries } from '@/lib/db'
import guestbook from './state'
export const sign = defineAction({
name: 'sign',
description: 'Add a message to the guestbook',
input: z.object({ name: z.string().min(1), message: z.string().max(280) }),
state: guestbook,
expose: 'public',
effects: ['entries'],
run: async (input, ctx) => {
await entries.add(input)
// a form submit re-renders the page; agents get a delta instead
if (ctx.via === 'ui') refresh()
},
message: ({ input }) => `Signed as ${input.name}`,
})Use it in the page like any server action. Wrapping the form in <Affordance> marks it for agents that read the DOM:
import { Affordance } from '@affordances/react'
import { sign } from './actions'
export default function Page() {
return (
<Affordance action={sign}>
<form action={sign}>
<input name="name" />
<textarea name="message" />
<button>Sign</button>
</form>
</Affordance>
)
}Add the provider
<AffordancesProvider> loads the current page's graph in the browser and registers each available action as a WebMCP tool. Give it the current path, and refresh the server-rendered UI after an agent changes something:
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { AffordancesProvider } from '@affordances/react'
export const Agents = ({ children }: { children: React.ReactNode }) => {
const pathname = usePathname()
const router = useRouter()
return (
<AffordancesProvider path={pathname} onDelta={() => router.refresh()}>
{children}
</AffordancesProvider>
)
}Render <Agents> around {children} in your root layout.
Read the graph
Start the dev server and ask for the page as an agent would:
curl -H 'Accept: application/vnd.affordances+json' localhost:3000/guestbook{
"v": "0",
"page": "/guestbook",
"route": "/guestbook",
"entities": { "entries": [] },
"actions": [
{
"name": "sign",
"description": "Add a message to the guestbook",
"input": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1 },
"message": { "type": "string", "maxLength": 280 }
},
"required": ["name", "message"]
},
"expose": "public",
"available": true,
"effects": ["entries"]
}
]
}Call the action
curl localhost:3000/guestbook \
-H 'Content-Type: application/vnd.affordances+json' \
-d '{ "action": "sign", "input": { "name": "Ada", "message": "Hello" } }'{
"ok": true,
"changed": { "entries": [{ "name": "Ada", "message": "Hello" }] },
"message": "Signed as Ada"
}The response is a delta: the new value of each entity the action declared in effects, and the message to repeat to the user. /.well-known/affordances.json now lists /guestbook and its sign action.