vlt / affordances
  • Quickstart
  • Concepts
  • Guides
  • Reference
  • For agents
  • Why Affordances
  • Quickstart
  • Benchmarks

Concepts
  • Overview
  • Actions
  • State
  • Expose
  • Guards and confirm
  • Deltas
  • Plugins

Guides
  • Overview
  • React and WebMCP
  • MCP
  • Lint
  • Security

Plugins
  • Overview

Reference
  • Overview
  • Protocol
  • Options
  • CLI
  • When it runs
  • Keep it small
  • Typing actions against state
  1. Concepts
  2. /
  3. State

State

A route's state.ts says what the page shows, as entities. It's loaded for the graph and again after every action.

state.ts sits next to a page and default-exports defineState(loader). The loader returns the page's entities: plain, JSON-serializable data, keyed by name.

app/products/[slug]/state.ts
import { defineState } from '@/lib/affordances'
import { getProduct } from '@/lib/catalog'
import { getCart } from '@/lib/cart'

export default defineState(
  async ({ params, ctx }) => ({
    product: await getProduct(params.slug as string),
    cart: await getCart(ctx.session),
  }),
  { nav: ({ cart }) => (cart.count > 0 ? [{ to: '/checkout', label: 'Checkout' }] : []) },
)

The loader receives:

ArgumentTypeDescription
paramsRecord<string, string | string[]>The route's dynamic segments, as Next names them
searchParamsURLSearchParamsThe page's query string, without affordances
ctxCtxThe caller's session and request; see Actions

The optional nav function turns the loaded state into links an agent can follow. A link's when is a human-readable condition, like "cart.count > 0".

When it runs

The handler loads state for three things:

  1. Building the graph for a GET.
  2. Evaluating the guard before an action runs.
  3. Reloading after the action ran, to compute the delta.

So the loader should be cheap, and it must use the context it's given. A loader that reads the session from somewhere else won't see a session an action just started.

Keep it small

Entities are what the page shows the same user, not everything you know. Agents read the graph as tokens, so about 2,000 tokens is a good ceiling. Summarize long lists (a count and the first page, say) rather than sending them whole.

Typing actions against state

Pass the state to an action's state option. Its guard and summary then receive the loaded entities with their types, and effects only accepts entity names that exist:

TypeScript
import product from './state'

defineAction({
  // …
  state: product,
  effects: ['cart'], // 'cart' must be a key of the state
})

PreviousActionsNextExpose

On this page

  • When it runs
  • Keep it small
  • Typing actions against state
Edit this page

Make your app agent-operable

Give every page a graph and every server action an agent path: HTTP, WebMCP, and MCP, with the same session and the same checks. One command sets it up.

Read the quickstart