Agent-First UI v0.4.0: A Session Is a Lifetime, Not a Question
One interface, one person, one answer was almost right. The part that was wrong is that some interfaces are not a question: they show changing state while a person acts on it. v0.4.0 adds a bounded full-duplex channel that carries Provider values and refuses to mean anything by them, and finishes the delivery boundary around it — one page assembly, one delivery call, and an ending every delivery can express.
Historical API note (2026-08-16): this article records the v0.4.0 design. The raw channel described below has since been removed. Current AFUI uses one typed session runtime for retained state, calls, ordered events, reconnect/replay, and managed blobs; trusted pages enter it only through
afui.connect(). See the currentdocs/providers.mdbefore implementing a UI integration.
The previous release said the hard part of handing a person a UI is delivery, not rendering: a credential, an origin, a lifetime, a window, a cleanup, and an answer that comes back typed. That still holds. What it got slightly wrong was the last noun.
“One interface, one person, one answer” describes a decision. It does not describe a panel that shows a database’s live load while you ask it to explain a query, or a review window where the work in front of you keeps changing while you refine it. Those have one person and one interface and one ending, and in between they have traffic — in both directions, on nobody’s turn.
Every program that needed this improvised it privately: one invented a
server-sent event stream, one settled for a <meta refresh>, one wrote a
sequence-number reconciliation over a byte stream. Four improvisations of the
same missing thing is the signal this project treats as evidence.
The channel is transport, and refuses to be more
let (session, channel) = UiSession::<()>::new("inspector", "live_status")?
.with_channel::<SurfaceCommand, SurfaceUpdate>()?;
The surface opens a WebSocket at SESSION_CHANNEL_PATH relative to its own
session URL. Every frame is exactly one JSON-encoded value of the Provider’s
own type — in either direction, whenever that side has something to say.
What AFUI deliberately does not do is the interesting half:
- it does not wrap values in an envelope, assign request ids, or pair a reply with a request;
- it does not retain, replay, or present history;
- it does not decide which direction leads, or what a value means.
Each of those would be a protocol, and a protocol here would have to be right for every program that ever attaches a channel. The connection has already selected the session; a program that wants request/response can put ids in its own type, and one that wants a firehose of snapshots does not have to pretend its data is a conversation. This is the same boundary as the rest of the crate: AFUI still does not know what a case, an invoice, or a query is, and now it also does not know what a turn is.
Outcome<T> is untouched. Any number of values may cross a channel; none of
them completes the session, and complete still succeeds at most once. The
invariant was never one question and one answer — it is one person, one
interface, one bounded lifetime, one terminal outcome, and the channel is
what makes the difference legible.
Backpressure is the whole flow-control story
Both directions are bounded — 32 queued values by default — and one value may be at most 1 MiB. AFUI never drops a value to stay under either bound, and two consequences are worth designing for rather than discovering:
A send with nobody listening eventually waits. A session can be delivered
and left unopened — a link nobody clicked, a card nobody expanded — while the
program keeps pushing. Once the queue fills, send waits. A loop pushing state
on a timer therefore stops on its own timer, and the surface that finally
connects gets the backlog, oldest first, rather than the current state. If the
newest value is the only one that matters, keep it in your own state and send
from there.
Not reading blocks writing. One connection carries both directions, so a
surface-to-program queue left full stops that connection reading and writing.
Call recv for as long as the session is open, even in a program that only
pushes.
Neither is a bug to route around with a larger capacity. Capacity buys slack for a burst; it does not make a slow reader fast.
One surface is connected at a time, and the slot belongs to the connection that holds it — a page that reloads, a laptop that sleeps, a proxy that drops the socket all end one surface and start another on the same session.
One page assembly instead of four
Four programs had each hand-written the same sequence: resolve template sources,
apply a strict template policy with the escape hatches removed, render, refuse
smuggled markup in the rendered output, require the runtime marker exactly
once, require every element id the runtime binds to, and only then splice the
script in. UiPage::builder(...).render(document) is that sequence, once, in
the order that makes it a safety property — checking after injection would flag
the program’s own trusted script the moment it resembled what is refused
everywhere else.
It arrives as a page feature that is orthogonal to session rather than
layered under it: assembling and checking a page costs a template engine and
binds no port.
| Feature | What it adds | What it costs |
|---|---|---|
| (none) | the frontend script guard, frontend discovery, the trust gate, the per-file overlay | file reading and hashing — no server |
session (default) | sessions, channels, hosts, mounts, windows, credentials, security policy, the registry | axum with WebSockets, tokio, tower |
page | the template policy, render, and the rendered-markup and element contract | a template engine — no port |
serve | the reverse proxy and the shell above it | a streaming HTTP client |
Three ways to reach a person, one call
window, link, and session were three vocabularies invented separately by
whoever needed them. They are one axis now, resolved by a single
UiDeliveryPlan::deliver() with an explicit priority: an explicit mode beats
the AFUI_DELIVERY environment default, which beats window.
link is the one that cannot be a default. A program offers it only by calling
with_link_expiry — an address that outlives the process handing it out is a
decision somebody has to make on purpose, so a program that never makes it does
not offer link at all.
Every delivery can express an ending
A window ends when you close it. A page on your phone went into your pocket still painted, and nothing ended.
POST <access_url>__afui/end now ends a session under every delivery, at the
same bar as viewing it: whoever holds the URL can end it. The whole __afui/
prefix is reserved ahead of the program’s own router, so today’s two routes and
tomorrow’s cannot collide with something already routed there.
Served pages also end on a clock: idle with nobody attending, then a grace
window in which everything still works and the page says out loud that it is
about to stop, then over. --idle-minutes and --grace-minutes set them and
0 means no limit. The grace period only ever renews the same address, because
silently replacing the credential under a half-filled form is exactly the
behaviour that made this worth fixing.
And “is anyone waiting on this?” is a fact a listing can answer. It is
automatic when a session returns a value, and awaiting_person() states it for
the shape the automatic rule cannot see: several items settled one at a time,
where the window closing is the only thing that ends the session.
The page you actually look at
The session list is itself a frontend now — afui frontend init afui sessions
scaffolds a replacement, held to the same contract, the same fingerprint trust,
and the same refusal to start when a required element went missing.
The built-in one grew up this release. Every action reports itself: pending,
done, or failed with a reason, instead of a silent fetch whose outcome showed up
whenever the next poll did. Controls needed rarely moved behind a menu. Remove
became Hide and End became End session, because one of them ends the work
behind the card and the other only tidies your view of it. The stylesheet
follows the system theme and a coarse pointer. Card size is remembered per
(provider_id, ui_kind), so the second terminal from the same program opens at
the size you chose for that kind of card rather than at a stranger’s default.
Also in this release
Windows browser discovery checks the fixed Chrome and Edge install locations
before falling back to a PATH search, so opening a window is no longer a
guaranteed failure there. primary_lan_ipv4 is reachable without the session
feature — it is a std::net computation that binds nothing, and a program with
its own listener needs the same probe.
The macOS window applet stopped polling WKWebView.title from its idle handler:
that bridges an Objective-C object over WebKit IPC on a timer and can race a
frame-state update into a crash. Window visibility is an AppKit scalar and is
all the handler needs.
agent-first-data moves to 0.33.0.