Tutorial: build your first operable surface


This walks through one real verb on one real surface in the live stack, piece by piece, in the order you'd build it: item.archive on a mail row. Every file and line quoted below is code the running site executes, and the request/response at the end is captured from an integration test that POSTs to the real door and reads the ops back off a live SSE connection. The full contract is AI-INTERFACE.md; this page is the fastest way to see it work before reading the whole thing.

The goal: one surface a human can operate and a decision-maker (a reasoner; a real model at ROADMAP.md's M★) can act on, through the exact same door, validated against the same closed vocabulary, rendered back the same way.

Where it lives. The site is one app window; the home route / is the desk, and its rail routes you to /mail, where each letter is an operable item surface. That row is what we make operable and what the archive verb targets.

The five pieces, in build order

1. Name the surface and the verb, the contract

Everything operable is declared in one closed registry, ai/contract.ts, never a magic string elsewhere:

export type SurfaceKind = "item" | "reflection" | "say-stream" | "screen" | "chat-log" | "notepad" | "field";
export const surface = (kind: SurfaceKind, id?: string): Surface => (id ? `${kind}:${id}` : kind);

export type ActionName =
  | "item.archive" | "say.set" | "say.stream" | "demo.run" | "desk.stop" | "chat.send"
  | "note.append" | "note.replace" | "navigate" | "field.set";

export const ACTIONS: Record<ActionName, ActionDef> = {
  "item.archive": { name: "item.archive", depth: "light", accepts: ["item"],
    description: "Archive an item (stands in for task.complete on the optimistic light path).",
    payload: {}, hints: { idempotent: true } },   // re-archiving is a harmless no-op
  // …
};

"item" is a surface kind; "item.archive" is a verb whose accepts list contains it. An address like item:mail-welcome (built with surface("item", "mail-welcome"), never hand-concatenated) is what render ops target. The registry also carries the verb's calling contract, an empty payload (item.archive takes no arguments) and its hints (idempotent, so a replay is harmless), which is what a reasoner reads from the manifest to invoke it safely.

2. Mark the surface in markup

The mail row (view/components/molecules/mail-row/mail-row.html) declares itself operable on its root element:

<a class="mailbox__item" data-kind="item" data-accepts="item.archive"
   data-bind-href="href" data-bind-data-folder="folder" data-bind-data-surface="surface">
  <span class="mailbox__item-from" data-field="from"></span>
  <span class="mailbox__item-subject" data-field="subject"></span>
  <span class="mailbox__item-snippet" data-field="snippet"></span>
</a>

data-kind plus data-accepts are harvested into the AI manifest, so the verb this component accepts is declared once, here, not hand-listed anywhere else. The data-surface value is computed server-side (server.ts sets surface: item:mail-<id> on each message view model), so the row that renders for the welcome letter carries data-surface="item:mail-welcome". That address is what render ops target and what the live-DOM manifest (and the x-ray overlay) reads back off the page.

3. Wire the one door, the composition root

src/server.ts, the only place BATCH, GRAIN, MILL, and the app meet, builds the interaction layer once:

const stream = createStream();
const reasoner = makeStubReasoner({ failRate: Number(Bun.env.AI_FAIL_RATE ?? 0) });
const aiLayer = createInteractionLayer({
  reasoner, stream,
  archiveItem: async () => undefined,
  renderSurface: async () => "",
  logSink: createStreamLogSink(stream),   // record every door crossing to the timeline (§5g)
});

stream is the OpChannel port (push-to-a-session); archiveItem and renderSurface are the scoped write capability the reasoner is handed, so GRAIN never reaches storage on its own. Every request that reaches POST /intent ends up at aiLayer.handleIntent(...) (src/routes/ai-routes.ts), which validates the intent against the registry from step 1 before it reaches your code (ai/interaction-layer.ts):

if (!isAction(intent.action)) { /* reject: unknown verb, echo the known ones */ }
else if (!ACTIONS[intent.action].accepts.includes(surfaceKind(intent.surface))) { /* reject: wrong surface kind */ }
else decision = await reasoner.decide(intent, tools);
Honest note on this repo's wiring. When the /loop board retired (2026-07-26) it took the only live domain backing for item.archive with it, so archiveItem and renderSurface collapse to stubs above. The crossing still validates, commits, and logs (see the captured transcript below); only the host-side re-render is a no-op today. Where a visitor actually watches a letter get archived (the /mail list), the desk drives the reader's own Archive control through its client door (src/ai/desk-door.ts), the same button a human clicks, so the visible move stays honest without a server backing. See the TODO(owner) at the end.

4. Teach the reasoner the verb

ai/reasoner.ts is the single writer, the only place a verb's effect gets decided. It reaches storage and rendering through scoped tools (ReasonTools), never directly. item.archive is the light path:

// item.archive: commit the write, then emit the confirmed (clean) fragment.
const id = surfaceId(intent.surface);
await tools.archiveItem(id);                          // the real write, through the scoped tool
const html = await tools.renderSurface(intent.surface); // the committed (clean) fragment
return {
  ok: true,
  reply: "Archived.",
  ops: [{ target: intent.surface, op: "replace", html, provenance: "ai", commit: "committed" }],
};

Note what this is not: there's no bespoke "archive endpoint." A verb's whole behavior is this one branch, write then hand back a RenderOp addressed at the surface it touched. Set AI_FAIL_RATE=1 and the same branch takes the rollback path instead: a flash op with provenance: "system" and no write, so the optimistic state clears and the failure surfaces.

5. Drive it, grade-as-signal made visible

A click on a control marked data-action="item.archive" is turned by the client dispatcher (grain/scripts/ai-dispatch.js) into POST /intent. The HTTP door always stamps source: "user"; the client can never self-declare as the AI (parseIntent in src/routes/ai-routes.ts ignores the client's source). What you built in step 4 runs identically no matter what triggered it: a plain click, or (once a real model is wired at M★) an in-process decision.

On the light path, the grade tells the story (AI-INTERFACE.md §5): the moment the control is used it goes data-commit="pending" and renders grain (in transit), and when the committed replace lands over SSE the surface settles clean. Grain means AI or in-flight, clean means human and committed, expressed with one op.

See the grade land in a live browser. On this repo item.archive's host re-render is stubbed (the honest note in step 3), so its committed fragment is empty and there is nothing to watch settle on /mail. The identical grade contract is live on the /grain showcase, driven by the same door: press Ask/Send (chat.send) and your message settles clean while the AI's reply streams into a bubble that stays grain; press ▷ Watch the AI act (demo.run) and the server's reasoner pushes a run of RenderOps back over SSE, a task badge flips to done and a drafted task stays grain. Same POST /intent + SSE path, same grade vocabulary as step 4, but with a live host effect you can watch in the browser today (pinned by e2e/grain-page.e2e.ts).

Run it yourself

bun run dev   # http://localhost:3000

POST /intent returns 202 immediately (fire-and-forget: the door acknowledges, the result lands over SSE), and the /stream connection pushes the confirmed op back. Captured from the integration test that spins up the real door and reads the ops off a live SSE connection (src/routes/ai-routes.integration.test.ts):

POST /intent {"source":"user","session":"s1","screen":"mail","surface":"item:mail-welcome","action":"item.archive","payload":{}}
→ 202

event: op
data: {"target":"item:mail-welcome","op":"replace","provenance":"ai","commit":"committed","html":""}

event: op
data: {"target":"timeline","op":"log","provenance":"user", …}   // the human's request

event: op
data: {"target":"timeline","op":"log","provenance":"ai", …}     // the AI's response

Two things to read here. The committed replace is the op step 4 returned, addressed at the surface it touched; its html is empty on this repo because the host-side write is a stub today (the honest note in step 3). And every crossing, the request and the response, is recorded to the timeline surface as a log op, source-tagged, one door and one format for both operators (AI-INTERFACE.md §5g).

Alongside it, GET /ai/manifest?screen=mail returns the machine-readable map of what's operable, the same registry from step 1 telling you (or a model) what's invokable right now. The manifest route advertises the global-chrome surfaces (reflection, chat-log); to see the mail rows themselves as operable item surfaces with item.archive on them, open /mail?xray (the x-ray overlay) or run context in the terminal island, both read the live-DOM manifest straight off the rendered [data-surface] markup from step 2.

Add your own verb

The procedure above generalizes to any new verb. The bare checklist lives in MAKE-A-SURFACE-OPERABLE.md; in short:

  1. ai/contract.ts add the ActionName plus its ACTIONS entry (accepts the right

SurfaceKinds, with a payload schema and hints).

  1. A reasoner branch (ai/reasoner.ts) decide what the verb does, return the RenderOps.
  2. A component marks itself with data-kind plus data-accepts, no other wiring needed for the

manifest to pick it up.

  1. A test per tier, a reasoner unit test and a door integration test (see

CONVENTIONS.md §6).

  1. Sync AI-INTERFACE.md's vocabulary section.

For adding a whole component or a new render-op kind, see ADD-A-COMPONENT.md and ADD-A-RENDER-OP-KIND.md in the developer docs hub.


Note on this repo's wiring. This tutorial builds item.archive end to end through the real door, and the door-level transcript above is a genuine integration test. Its visible host re-render is intentionally a no-op on the live site: the domain backing retired with the /loop board (2026-07-26), so archiveItem / renderSurface are stubs and the committed fragment is empty, and the /mail archive a visitor sees is a client-island DOM move driven by the desk, not a server-door re-render. To watch a crossing settle grain then clean over the server SSE channel in a live browser, piece 5 points at the /grain showcase's chat.send / demo.run controls, which drive the same door with a live host effect. Re-wiring a host-surface backing for item.archive against a real /mail-style board would make its own visible end-to-end reproducible too, but is not required for the tutorial to be honest.