Agent-First UI v0.5.0: The Boundary Moves to Where the Copies Were

by Agent-First Kit Contributors

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:

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:

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

Getting it

$ brew install agentfirstkit/tap/afui
$ cargo install agent-first-ui