Agent-First UI v0.3.0: Everything Except the Interface
Handing a person a UI is not a rendering problem, it is a delivery problem: a credential, a lifetime, an origin, a window, a cleanup, and an answer that comes back typed. v0.3.0 replaces the view-hint protocol with a library that owns exactly that boundary and never sees a domain value.
An agent can ask a question in chat. What it cannot do in chat is show you twelve drafts and let you approve four, or put a diff in front of you, or hand you a form that validates as you type. So the moment a task needs human judgement on something shaped like a page, the agent has nowhere to put you.
The first two versions of AFUI answered that by describing the page. A tool published facts and capabilities as data, a host you trusted rendered them, and nothing the tool sent could execute on your machine. The safety argument was sound. The scope was wrong, and it was wrong in a way that only shows up after you try to build something real on it: a protocol that describes interfaces has to keep growing a vocabulary for every interface anyone wants, and it is still not finished when a program has an interface it already knows how to draw and simply needs to get it in front of a person.
v0.3.0 deletes the protocol and keeps the other half.
The problem is delivery, not rendering
Drawing a page is a solved problem with a hundred good answers. Handing that page to exactly one person, once, safely, and getting a typed answer back is not — and it is the part every program improvises privately.
Improvising it means: bind a port, mint a token, decide when the token stops
working, set response headers, decide which origins may talk to you, open a
browser, and then somehow determine whether the person actually answered or
just closed the tab. That is six security decisions and a lifetime model, and
the failure modes are quiet. A token that never expires. A page advertised on
0.0.0.0. A tab left open on your real browser profile, holding your real
cookies. A call that waits forever because nobody told it the window is gone.
None of that is about what is on the page. So none of it should require knowing what is on the page.
The boundary
You bring an Axum Router and you decide what a finished interaction means.
AFUI brings everything on the other side of that line:
- a revocable access credential, and the decision of when it lapses;
- secure response headers, with exact-origin and exact-host checks;
- an isolated Chromium-family window on a disposable profile — never your browser, never your cookies;
- cleanup, and a typed outcome:
Completed(T),Closed, orExpired.
A session carries a stable provider_id, ui_kind, and session_id, so
several interfaces can be open at once and each answer returns to the caller
waiting for it.
AFUI never renders, never templates, and never sees a domain value. It does not know what a case, an invoice, or a query is — and because it does not, it never needs a new version to accommodate one. That is the same trade the old protocol refused to make, and it is the whole difference between a library and a spec with an open-ended vocabulary.
Lifetime belongs to the delivery, not the session
The interesting design result is that “when does this expire?” has no session-level answer.
The same interface, reached three different ways, wants three different lifetimes. Opened as a window in front of someone, it should not expire at all — a person who leaves it open all afternoon is still using it, and the window itself is the bound: it ends when a handler completes it or the person closes it. Handed out as a URL to someone who is not at this machine, it must expire, because nobody is watching that credential. Left open as a long-running console, what should expire is idleness, not elapsed time.
One default cannot express all three, and a session-level ttl field forces
the caller to pick the wrong one twice. So a window bounds itself and never
expires, and a URL delivery has to name its lifetime: UiExpiry::After for a
fixed span, or UiExpiry::Idle for one that slides forward while the page is
actually being used.
Expiry is a property of how the interface reached the person, not of the interface.
Sessions are not ports
The obvious implementation ties a session to a listener: one interface, one port. That is wrong twice. Two concurrent interfaces then need two ports, which a container with one mapped port or a machine behind a single tunnel cannot give them. And a program that already runs a server has to surrender a port just to ask a question.
UiHost binds a listener once and takes as many sessions as you like. Each is
reached by its own credential, each answer returns to the call waiting for it,
and ending one leaves its neighbours serving.
UiMount is the same routing with no listener of its own: nest its router()
under a prefix on the Axum router you already have. Those are ordinary sessions
— same credentials, same expiry and revocation, same security policy, same
typed outcome.
What is open, across processes
A session lives inside whichever process created it, so by construction nothing could see across them. If two programs each have something waiting for you, there is no place that knows.
Every active session now announces itself to a per-user registry, and afui session list answers “what is waiting for me?” Liveness is decided by asking
whether the host still answers, not by checking whether a pid exists — a
process can outlive the session it announced. Entries are written 0600 under
a 0700 directory, and a stale one is pruned rather than offered.
The credential is deliberately not in that listing. A session URL carries one,
and stdout is where an agent reads; afui session open <id> hands the URL
straight to a window instead of printing it. AFUI_NO_REGISTRY=1 keeps a
session out entirely.
Not every interface worth listing is one this crate serves. UiUpstream
announces one that already exists behind someone else’s listener and someone
else’s credential, so a reader of the list cannot tell which kind it is
holding. What AFUI takes on there is the listing and only the listing: a
credential it did not mint is not one it can revoke, and ending an announcement
withdraws the entry rather than the URL.
127.0.0.1 is the wrong address for a phone
An agent works on your laptop. You are holding a phone. Every interface above is bound to loopback, and on a phone, loopback is the phone.
afui session serve --listen <ADDRESS> puts the same list on one page with
each session proxied onto an address another device can reach. Two things it
deliberately will not do. It is not a transport — it binds a port, and
crossing anything to reach that port is WireGuard, Tailscale, an SSH tunnel or
a TLS reverse proxy, all of which exist and are better at it. And it does not
hand its own credential to anything it frames: each framed UI is reached by a
separate short-lived credential on an origin that is not the page’s, forwarded
upstream through a header allowlist with no line for Cookie,
Authorization, Origin or Referer. Cookies still work — they are held per
proxied session here rather than by the browser — and a WebSocket is relayed
once both ends have agreed to upgrade, because a remote framebuffer needs that
and nothing above it can see inside it anyway.
There is a real browser constraint underneath. Framed sessions are sandboxed
into an opaque origin, which is what isolates them from each other and from
the page — and also what denies them localStorage, so a third-party client
that reads storage without guarding it refuses to load.
--session-origin-host <HOST> moves the isolation onto a boundary the browser
draws anyway: each session becomes s-<credential>.<HOST>, same-origin with
itself, still unable to reach the page or any sibling. localhost costs
nothing, since browsers resolve *.localhost locally with no DNS entry at all.
The shell and its proxy sit behind the serve feature, off by default.
Paying for what you use
Keeping a frontend honest is not the same job as hosting one, and the crate is tiered so a caller does not buy a web server to do the first.
| 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, hosts, mounts, windows, credentials, security policy, the registry | axum, tokio, tower |
serve | the reverse proxy and the shell above it | a streaming HTTP client |
The featureless tier matters because a program whose human UI is an optional
feature still has to refuse a smuggled <script> in the build where that UI is
compiled out. reject_frontend_script therefore has to be reachable without
dragging a web server into a build that has no port to bind.
Overrides you can trust by fingerprint
The interface a program ships is a default, not a decision. afui frontend
scaffolds a replacement, previews it, and enables it when it is ready.
Trust is content-addressed rather than path-based: workspace code stays ignored
until you enable its exact fingerprint, and any later edit revokes that trust
until you look again. AFUI_SAFE_MODE=1 ignores overrides entirely. The
overlay is per file, so replacing one page keeps every other file the program
ships. Two rules are enforced rather than described, because binding behaviour
under a CSP nonce depends on them: an override may not supply a file whose name
says it is a script, and the guard refuses one hiding inside a template it does
supply.
What real use corrected
Several defects were only reachable once the library was doing the delivery rather than describing it.
The advertised URL 404’d. nest("/{secret}", ...) serves /{secret} and
/{secret}/style.css but not /{secret}/ — which is exactly what
access_url() hands out, and the trailing slash is what makes a page’s
relative assets resolve. The symptom was a blank page with 200s on every asset.
Stripping the credential before routing fixes it, and makes the constant-time
compare live.
The window opened with --new-window, which is a browser tab, not an app
window; it is --app= now. No CSP nonce existed, so a page that inlines a
script could not be served at all. The bind address was used as the advertised
address, so a session bound to 0.0.0.0 advertised 0.0.0.0. And the markup
guard had drifted into four copies, one of which refused three MiniJinja escape
hatches the others allowed — now one implementation, at the stricter behaviour.
Also in this release
The crate moved from rust/ to the spore root like every other Rust spore,
deleting the special cases the project registry, install script, release
pipeline and test driver each carried for it. The spore gained the skill it
never had. The served page reads properly on a narrow screen, and an unknown
session gets a real not-found page. agent-first-data moves to 0.32.0.