Agent-First UI v0.4.0: A Session Is a Lifetime, Not a Question

by Agent-First Kit Contributors

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 current docs/providers.md before 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:

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.

FeatureWhat it addsWhat it costs
(none)the frontend script guard, frontend discovery, the trust gate, the per-file overlayfile reading and hashing — no server
session (default)sessions, channels, hosts, mounts, windows, credentials, security policy, the registryaxum with WebSockets, tokio, tower
pagethe template policy, render, and the rendered-markup and element contracta template engine — no port
servethe reverse proxy and the shell above ita 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.