Agent-First UI v0.3.0: Everything Except the Interface

by Agent-First Kit Contributors

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 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.

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, hosts, mounts, windows, credentials, security policy, the registryaxum, tokio, tower
servethe reverse proxy and the shell above ita 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.