Agent-First UI v0.5.0: The Boundary Moves to Where the Copies Were
v0.4.0 shipped a channel that refused to mean anything, and every page then built the same meaning on top of it. Roughly seven hundred lines of page runtime, four private event streams, four hand-written content policies and five wildcard error arms later, the measurement is unambiguous: AFUI now owns the typed session runtime, the page kernel, page composition, delivery facts and credential routing — plus three places where its own contract said something the code did not do.
The last release drew a line and defended it. A session got a full-duplex channel that carried a Provider’s own values and refused to mean anything by them: no envelope, no request ids, no retention, no replay. Every one of those would have been a protocol, and a protocol has to be right for every program that ever attaches one.
That reasoning was sound and the result was still wrong, for a reason only usage shows. Refusing to decide does not remove the decision — it relocates it. Each program that attached a channel then wrote the same missing half for itself, and the copies were measurable: roughly seven hundred lines of page runtime repeated across pages, four privately invented event streams each with its own sequence field and reconnect flag, four hand-written nine-directive content policies, five error-matching blocks that all ended in a wildcard arm.
Four independent improvisations of one missing thing is the evidence this project acts on. So the boundary moves — not to where a design document put it, but to where the copies actually were.
One runtime, one entry point
The raw channel is gone. In its place is a typed session runtime with four shapes, chosen because they are what the copies kept rebuilding:
- retained state — a latest-value slot a page reads on arrival and on every change;
- calls — one handle carrying progress, result, error and cancellation, so a request that answers twice is a conversation rather than a broken promise;
- ordered events with a bounded replay window, so a page that reconnects learns what it missed or learns that it cannot;
- managed byte bodies, so handing a browser an image is not a private route.
A trusted page enters through exactly one door:
const session = await afui.connect()
There is no second place to reinvent a UI protocol, which was the actual cost of having no first place.
Three behaviours that were load-bearing in real pages and promised nowhere are now written down and tested. Republishing an equal state still reaches the page, because a state published purely as an invalidation is a real pattern and a later “skip it if nothing changed” optimisation would have broken it silently. Only the newest publication is guaranteed to arrive, which is exactly what makes a state carrying a delta wrong under load. And an opening state is optional, so a page whose first screen is server-rendered takes the live signal as its only opening cue.
One race is documented rather than removed: retained state, ordered events and a call’s result are three channels on one socket, and AFUI orders none of them against each other. Ordering them would mean holding a publication behind a reply, and a publish that never waits for a viewer is worth more. So the two shapes that survive the race are written down, and the third — half the new data in the reply and half in the state — is named as the mistake it is.
A page runtime to write pages in
page_kernel_source() returns JavaScript a Provider concatenates ahead of its
own runtime, inside one function scope: one lexical afui binding, nothing on
window, no template, no DOM diff, no state store. Every text write is
textContent and every element comes from the caller. The boundary is
unchanged — a frontend still cannot supply JavaScript, and this reaches a
browser only because a Provider splices it into its own trusted runtime.
What it carries is the list of things that were being written again: session-root
derivation, a fetch wrapper, correlated request/reply, a stream that reports
open, resumed and reconnecting as three different things, an aria-live
status line, in-flight disabling, a debounced save around a base hash,
hash-fragment survival, an unload guard, and visibility-gated polling.
Its test suite is worth describing, because every case is a mistake a hand-written copy actually made: a leading slash escaping the session root, a cached answer under a revocable credential, a correlated reply also arriving as a push, a sequence gap the browser’s own reconnection opened, a stale save reply moving the base hash backwards, an unload guard warning about nothing. A sequence that goes backwards is a gap too — a restarted stream renumbers from the beginning, and a page reading straight through that paints an old screen over a new one.
Writing a real page against it found the design wrong once, which is the point of
writing one: send assumed one request meant one reply, and a page whose server
answers with a started and then a result had no shape to use. ask is the
conversation; send is sugar over it; which update ends an exchange stays the
Provider’s protocol.
A classification the compiler can check
Error::kind() returned &'static str. Nothing in Rust exhaustively matches a
string, so every consumer was forced into a wildcard arm — while kind()’s own
match deliberately has none, because a classification added later should stop
compiling until somebody decides what it means.
That is a promise AFUI kept for itself and made impossible to keep anywhere else, and the cost was concrete: consumers aimed their wildcard at a frontend-unreadable code, so a closed session runtime, a machine with no browser and an unreadable config all reported as a frontend that could not be read — sending a person to a file with nothing wrong with it.
UiErrorKind is closed and deliberately not #[non_exhaustive]. Every word
stays the same, so no emitted code moves; what changes is that a consumer can
now be exhaustive, and a new variant stops their build until they say where it
goes.
Recovery moved out of Display at the same time. A message says what happened;
Error::hint() says what to do, and only where there is something to do — safe
mode is suggested for a failure an override could have caused, never for a
built-in page the Provider itself could not render.
Delivery has one reading
A plan resolved which delivery a run uses and then kept it, so a Provider that had to name the delivery in a readiness report resolved it a second time against the same inputs — two readings of one decision, and two chances to disagree.
UiDeliveryPlan::mode() reads the resolved word before anything is served, and
facts() returns all five delivery fields as one serializable value.
Outcome::ending() gives completed, closed and expired one spelling
instead of one per tool. link is refused while the plan is built rather than
when delivery starts, so a command that announces its mode before it blocks can
no longer announce one that was already refused.
The delivery words moved below session into a featureless tier, because
choosing between three strings needs no port, no credential and no browser — and
a Provider whose UI is an optional feature still declares the same flags in the
build where the UI is compiled out. An agent reading --help sees one contract,
not one per build. UiDeliveryOffer makes that a single value: the same offer
builds the flag and builds the plan, so a command cannot advertise a word it will
not honour.
One redaction fix rides along. Merging delivery facts into a readiness event and
emitting it the ordinary way published link_url_secret: "***" — correct
everywhere except the one event whose entire job is handing that URL to a person.
It is published as link_url now, with no secret suffix and nothing for a caller
to disable. The facts struct still calls it link_url_secret, because code
holding that value is holding a capability.
Credentials for a listener that outlives the session
The usual shape is one credential per session. The other shape — a long-lived
listener with a domain API that hands out access to the same UI on request and
takes it away again — was being reimplemented, and the copies lost things: the
Origin and Host checks, the reserved __afui/end route, and one copy was
forced to open a route under the reserved prefix to answer its own icon.
A missing Host check is not a style problem. Any name resolving to that socket
reached the interface behind it.
UiMount::external_access is AFUI’s own dispatch driven from outside: issue,
revoke, reap, with the same 404, the same Host and Origin checks, the same
response headers and reserved prefix a session gets. UiCredentialLease is the
other side, renewing three times per idle window so one lost renewal is not a
lapsed credential. is_ui_credential puts the credential’s shape here instead of
“sixty-four hex digits” written down in a consumer.
Composing a page instead of formatting one
Four programs wrote nearly the same nine-directive content policy by hand, and
two answered a header that failed to parse by quietly falling back to a
different policy — the one failure a security header must not have.
UiPagePolicy is a closed set of switches over the directives that actually
vary, with a floor of default-src 'none'. Every part is fixed ASCII or a hex
nonce, so there is no parse step and nothing to fall back from. Attaching a
channel to a page built without it is refused when the session mounts, because
the browser — not AFUI — is what refuses that connection at runtime, and nothing
on this side would otherwise hear about it.
The rest of the composition layer follows the same measurement:
UiSurfaceis the nine composition steps a decide page was writing for itself. A submission arrives as a string map and is parsed by a function the Provider wrote, so AFUI never learns what a field means — and it says in as many words that this layer will never gain validation, field types, conditional display, layout or grouping. Anyone who needs those needs a Router, which they have always had.UiDecisionRuntimegenerates the decide-page script instead of each page hand-writing an HTML attribute scanner that also counted declarations inside comments and<template>.UiPageBuilderchecks required attributes, attribute values and decisions on the parse tree it already built, so a frontend missing a hook fails to render instead of opening a window that silently does nothing.page_routesandUiBlobPolicyserve a page, its stylesheet, an override’s assets and any bytes a session hands a browser through one place: a caller-supplied media allowlist and size bound, a read that refuses to follow a link out of its root at every component, and a 404 for every refusal whatever the reason.- A baseline stylesheet is served at AFUI’s own reserved route, so an
override that replaces a page’s stylesheet still opens as a page that belongs
here. The reserved routes reach templates as a protected
afuiglobal rather than a literal copied into five programs with no compile-time relationship to the dispatch that answers them.
Where the contract said something the code did not do
Three fixes are in this class, and they are the ones worth reading twice.
Trust covered a directory; serving read the disk again. A frontend’s fingerprint is computed over the whole directory at resolve — and then every request read the file from disk a second time, so a long-running process served templates, CSS and assets edited after the check, while the README and the skill both said any later edit revokes trust. The trusted files are read once, when trust is decided, and served from that snapshot. A symlink anywhere under the directory is refused at resolve, naming the file, because the fingerprint describes a directory and a directory holding a link is not what it describes.
A session had more than one ending. Close, expiry and window-close each
built an outcome inside their own select! arm rather than writing to a shared
slot, so an answer that became ready in the same moment could be reported as
Closed or Expired depending on nothing but which arm was polled — telling a
caller the person walked away when they had just decided. There is one
irreversible slot now: every ending offers its outcome through the same
compare-and-set and reports whoever got there first, and a completion retained
past the end can no longer be told it won.
A lapsed page said nothing useful. A page that runs its attention clock out
stops answering, which is the point; what it stopped answering with was a bare
404, indistinguishable from a mistyped URL. Someone returning to their phone
twenty minutes later could not tell “this expired, get a new one” from “you
copied the link wrong”, and those are different next actions. The lapse check now
runs after the page’s own credential authorizes, which splits the two audiences:
the credential holder gets 410 Gone and three sentences — the address was
right, the sessions outlived this page, a new URL is one session serve away —
and everything else gets the same 404 as before, byte for byte. A listener that
volunteered “an AFUI page expired here” to anything that connects would be
answering exactly the question 404 exists to refuse.
The head and the place
Two things visible in one screenshot of a served workbench, both about the boundary rather than any one domain.
Each card carried two headers stacked: the frame’s own, which already had the
program’s name and what the session was on, and the page’s underneath saying the
same things again. afui.frame collapses that — a page that hears there is a
frame hides its own head and sends up the two things a frame cannot know, a
title and a status. Identity is never sent, so no card can be made to introduce
itself as something else, and that asymmetry is what makes the rest safe to
accept at all.
afui.place is the other one: a record opened over its list used to be a second
screen with no way out but a control the page drew, so the gesture a person on a
phone reaches for first left the whole page. A place is one history entry when
entered and not one per disclosure, with popstate in both directions, Escape,
and the keyboard handed back to whatever opened it. It renders nothing — whether
a place is a pane beside the list or a screen over it is a question about that
page’s width and content.
Both speak through one postMessage channel, because the frames are
cross-origin and sandboxed by design and reaching in would mean
allow-same-origin, which this crate has refused in writing. A size, a position,
a control’s name and another card are all still refused: a boolean, a title and a
status line are not capabilities, and what saying them buys is the row of screen
the second head was costing.
Testing through AFUI instead of around it
A test-support feature — not in the default set, never in a release binary —
carries AFUI’s trust fingerprint, a browser that records a URL instead of opening
one, an attention-policy writer, a registry waiter, and a client for the real
session runtime.
It exists because the alternative was each consumer reimplementing the fingerprint or the WebSocket envelope, and each copy agreed with AFUI exactly — which is the problem. They would have gone on agreeing right up until AFUI’s algorithm changed, and then several unrelated suites would fail for a reason none of them is about.
Breaking changes
- The raw session channel is removed. Pages enter the typed runtime through
afui.connect(); a private SSE route, sequence field and reconnect flag are no longer yours to maintain. Error::kind()returnsUiErrorKindinstead of&'static str. The words are unchanged; a wildcard arm is now a compile error worth having.Displayno longer carries a recovery action. ReadError::hint().allow_linkis gone.resolveoffers window and session;resolve_allowing_linkoffers all three, and the refusal names what is on offer.- The feature tiers moved.
cli(the delivery flag alone) andtest-supportare new; the shell and streaming proxy live insession, soservenow adds only the process signal its CLI verb waits on. - The link URL is published as
link_url. A Provider revealinglink_url_secretby hand should stop.
Getting it
$ brew install agentfirstkit/tap/afui
$ cargo install agent-first-ui