Building a Provider UI on AFUI

The README covers what AFUI hands you. This covers the three decisions you make before using it: whether the thing you have is a session at all, what an override is allowed to do to it, and — if your page returns an answer that matters — how to keep a replaced template from redefining what that answer is.

Is it a session?

Three tests. A UI that fails any one of them is not a session, and forcing it into one produces something that looks alive and answers nobody.

  1. There is a web UI. Either you serve it, or you point at one that already exists somewhere else.
  2. The ending has a name. A session that waits for a decision ends when the person submits something; a session someone just watches ends when they close it. There is no third kind that stays open forever.
  3. A process’s lifetime bounds it. Whoever created the session owns it: when that process exits, the session ends and its credential is revoked. Nothing outlives its owner.

A notification with no UI fails the first. A page that keeps serving after the command that made it has exited fails the third — that is not a session, it is a server you forgot about.

What a session’s shape decides

A session that waits for a decision returns a typed value: Outcome<T> where Completed(T) is the person’s answer. A session someone watches has T = () and never completes — it ends Closed.

That difference is not cosmetic. It decides the terminal outcome, but it does not give a Provider a second remote-lifetime policy:

Getting this backwards is the common failure: putting a timeout on the session itself kills work a person may still be using through another view, while putting separate clocks on direct and shared pages gives the same AFUI URL two different promises.

Say so when a T = () session still waits on a person

afui session list reports waits_for_decision for every entry, so a person or an agent looking at the list can tell a stopped agent from an open monitor before assuming nothing is blocked. It is true automatically for any session whose T is not () — handing a value back is, by definition, waiting on whoever supplies it. It has no way to see the other shape on its own: a session with T = () that a person is nonetheless expected to act on — several unrelated items settled one at a time, say, with nothing ever handed back. Call UiSession::awaiting_person on that session to say so.

The rule this implies: a session that does not wait for anyone should use T = (). Reaching for a unit struct instead of () just to look distinct gets automatically counted as waiting — harmlessly, since that direction is always safe, but not what you meant.

The live runtime is orthogonal to the terminal outcome

Some interfaces are not one request followed by one answer. A surface may show live measurements while accepting inspection commands, or receive progress while a person refines work in progress. That still has one bounded session lifetime and one terminal Outcome<T>; intermediate traffic uses UiSessionRuntime<Action, Reply, SurfaceState>.

Attach it with UiSession::with_runtime. The returned runtime is the Provider side; the trusted page calls afui.connect(). AFUI owns the only transport protocol between them: the versioned envelope, request identity, latest-state replacement, event sequence and replay, reconnect, call deduplication, cancellation, bounds, and shutdown. Provider payload types contain none of those fields.

Current state: replace, never queue

Use publish_state for the complete value a newly connected page should see. AFUI retains exactly the newest state, assigns its revision, and sends it before the connection becomes live. Publishing never waits for a viewer, so a timer continues running when the page is unopened or temporarily disconnected. A reconnect needs no Provider-owned resumed flag and no overflow fallback. Concurrent publications are linearized with their revisions, so an older revision cannot race back into the retained slot after a newer one.

Three properties of that retention decide how a page has to be written, and a page that guesses wrongly at any of them fails in a way nothing here would catch:

Nothing is ordered against anything else

Retained state, ordered events and a call’s result are three separate channels sharing one socket, and AFUI orders none of them against the others. A call that changes what the state shows may have its result arrive before the new state or after it. An event’s sequence orders it among events, and says nothing about anything else.

This is not a gap waiting to be closed. Ordering them means holding a publication behind a reply, and publish_state never waiting for a viewer is worth more than an ordering that a reconnect would break anyway.

So there are exactly two shapes for the page did something, and now what it shows is different:

The third shape — half the new data in the reply, half in the state — is the one that needs the ordering that does not exist. It works until the two frames arrive the other way round, and then shows a mixture of two moments with nothing logged. Pick one channel for the data and let the other one only say when, and make the refresh idempotent so that a later one simply wins.

Surface actions: one typed call handle

recv yields a UiCall<Action, Reply>. Read the action with call.action() and answer only through the handle:

The page’s runtime.call(payload, {onProgress, signal}) returns one promise. AFUI assigns the request id, resends an unacknowledged call after a wire drop, and replays an already completed result rather than handing the action to the Provider twice. The action is captured as one JSON value when call begins; later page mutation cannot change what a reconnect resends. Never add request_id, reconnect tokens, or acknowledgements to Action or Reply.

Ordered events: only when every occurrence matters

Use publish_event for an occurrence rather than a snapshot. AFUI assigns the sequence and retains a bounded replay window. A reconnect receives missed events or an onEventReset range when the cursor fell outside that window. An old cursor presented to a newly empty runtime resets to 0/0, so the next event starts at one instead of being mistaken for an already-seen event. Most screens want publish_state; choosing events merely because the source is a stream recreates a backlog where only the newest screen matters.

Managed byte bodies

Use with_runtime_and_blobs when a reply needs bytes that do not belong in JSON. Declare a UiBlobPolicy with the maximum body size and admitted media types. store_blob returns UiBlobRef; the page uses blobUrl, fetchBlob, and releaseBlob. AFUI owns the unguessable session-relative URL, no-store, nosniff, inline disposition, bounded retention/eviction, and cleanup when the session ends. Do not invent a private download route for per-call UI bytes.

The default runtime admits 32 unacknowledged calls in total, ordered-event replay holds 128 events, and every encoded frame is limited to 1 MiB. A full call bound produces a caller-safe, retryable ui_runtime_busy result instead of blocking the session transport. Use the explicit-capacity methods when those bounds are part of the interface’s measured shape. Build the page policy with allow_runtime(); a runtime attached to an AFUI-assembled policy that forbids same-origin connections is rejected before the page opens.

A session reaches a person one of three ways. UiDeliveryPlan resolves all three through one plan:

ModeDelivered asExpires
windowan isolated window this machine opensnever — the window is the bound
linkan AFUI page that proxies exactly this loopback sessionthe global AFUI attention policy: 15 minutes idle plus 5 minutes warning by default
sessionregistered only, reached later through afui session open or afui session servethe session never expires; each remote view uses the same AFUI attention policy
// `resolve` for a Provider that stays local; `resolve_allowing_link` for one
// that offers the LAN page as well.
let outcome = UiDeliveryPlan::resolve_allowing_link(explicit_mode)?
    .deliver(session, router)
    .await?;

When the caller must announce the AFUI-owned Link URL before it blocks, split the same plan at its handoff point:

let active = UiDeliveryPlan::resolve_allowing_link(explicit_mode)?
    .start(session, router)
    .await?;

if let Some(url) = active.link_url_secret() {
    publish_secret_link(url)?;
}

let outcome = active.wait().await?;

link_url_secret() is available only for link. It is named as a secret on purpose: the credential is part of the URL, so publish it only through the intended private handoff. deliver remains the shorter start + wait form for callers that do not need the URL in between.

resolve reads one thing besides its argument: AFUI_DELIVERY, at the fixed priority explicit --mode > AFUI_DELIVERY > window. Wire your own --mode flag with these same three words and pass None for explicit when the person did not give it. Do not give that flag a default value of its own — a default there is indistinguishable from someone having typed --mode window, so AFUI_DELIVERY would never be reached and an unattended run would always pop a window nobody is there to see.

link is opt-in per Provider: build the plan with resolve and a resolved link — from either source — is refused there, naming the modes that are on offer, and your own --mode flag should not list link as a legal value either. The refusal lands at resolve rather than at deliver on purpose: a Provider normally announces its resolved delivery before it blocks, and a refusal that arrives after that announcement produces a readiness report that was false when it was written.

The opt-in is only permission to bind a LAN listener. Lifetime is not part of it: attention_policy() is report-only, and the intervals come from AFUI’s global config.json under attention.idle_timeout_s and attention.grace_period_s.

Reporting a ready delivery

Everything a readiness report needs is one call, on either kind of active delivery:

let facts = active.facts();
FieldMeaning
modethe resolved delivery, spelled the way a person types it
session_idstable identity, shared with this delivery’s registry entry
link_url_secretthe bearer URL; present for link and nothing else
idle_timeout_show long this delivery may sit unattended; absent when nothing lapses
grace_period_show long the warning runs; present exactly when there is an attention clock

UiDeliveryFacts is facts and nothing else — no sentence to print, no notion of which field matters more, no opinion on whether a field belongs in your output at all. Those are yours. The names follow the conventions a structured output layer reads (_s for seconds, _secret for a bearer capability), so splicing the whole structure into an event gets correct handling by default; a Provider that deliberately publishes the URL renames that one field itself.

UiDeliveryPlan::mode() reads the same resolved word before anything is served, for a Provider that has to name the delivery earlier still. Do not resolve AFUI_DELIVERY a second time to learn it: two reads of one decision are two chances to disagree. UiDeliveryMode::description() is one factual sentence per delivery, for a Provider that wants one and does not want to write its own three-armed match over the same three facts.

Outcome::ending() is the counterpart at the other end: completed, closed, or expired. The completed case usually deserves a richer word of your own — what the person actually decided — but the other two carry no domain meaning, and every Provider spelling them for itself is how one tool’s closed becomes another’s cancelled.

link is the one delivery that reaches another device with no tunnel in the way: plain HTTP on this machine’s own LAN-reachable address, with no authentication beyond the credential already in the URL. Use it only on a network you trust, and treat that URL the way you would treat a password — give it to the person it is for, and nowhere else: not into a file, not into a commit message, not into a chat transcript you do not control.

When the page is not this crate’s to serve

A Provider that already answers on a listener it bound itself hands AFUI no Router. Name that private source with UiUpstream and give it to the same plan:

let active = UiDeliveryPlan::resolve_allowing_link(explicit_mode)?
    .start_upstream(
        UiUpstream::new("inspector", "console", upstream_url_secret)?
            .with_subject("running service"),
    )
    .await?;

if let Some(url) = active.link_url_secret() {
    publish_secret_link(url)?;
}

let outcome = active.wait().await?;

The meanings do not change for an upstream. window opens its private URL locally. link starts an AFUI-owned LAN listener and fixed-session shell; the upstream URL remains private behind the proxy, so its own listener may stay on loopback. session only registers it and has no AFUI expiry. The Provider may race active.wait() against its own shutdown signal; cancelling the wait drops the announcement.

AFUI cannot mint or revoke a credential on a listener it does not own. The upstream contract is therefore narrow: its owner keeps that private URL usable for as long as the ActiveUiUpstreamDelivery exists and revokes it afterwards. An internal crash-cleanup lease is fine; exposing that lease as the Link or Session lifetime is not. The person-facing remote lifetime remains AFUI’s attention policy alone.

window may open an HTTP or HTTPS upstream directly. link and session require a private HTTP upstream: both may pass the page through AFUI’s streaming proxy, which intentionally does not act as a TLS client or trust arbitrary upstream certificates.

Ending a session from outside it

Every session answers POST <access_url> + agent_first_ui::SESSION_END_PATH under its own credential, on every delivery: afui session close <id> sends it, and so does the “End” control on a served session’s card. Whoever created the session gets Outcome::Closed back — the same outcome as a person closing its window — never a manufactured Completed(T).

The threshold for ending a session is the same as the threshold for seeing it: whoever holds the URL already holds the whole credential, and can already do worse from inside a Provider’s own page (a terminal’s exit, for one), so refusing to let them end it from outside would be a boundary that only looks like one.

The whole __afui/ prefix is reserved. SESSION_END_PATH is the one public external-control path; live-runtime paths are deliberately not public API. Do not route on any name under the prefix, proxy through it, or otherwise give it a second meaning in your own Router — this crate’s dispatch answers for the whole prefix before your router is ever reached, so a route of your own under it is simply unreachable, not a conflict you would notice at runtime. Reserving the prefix rather than only today’s paths lets AFUI add another later without silently colliding with something you already routed there.

What an override may and may not do

An override supplies presentation. It may restructure freely — reorder, regroup, drop, rename, restyle — and that freedom is the point: an agent customises a Provider’s UI by writing files, not by editing the Provider.

Three limits hold regardless of what a template contains.

Runtime data may not choose or inject the UI code that runs. Data flows into a template. Data must never select which template executes. If a domain value can reach the page as markup rather than as text, that boundary is already broken — escape everything, including the values a provider returned to you.

The server stays authoritative. An override can be wrong, ugly, or send a malformed request. It cannot bypass typed validation, a lock, a base hash, an idempotency key, or a plan/confirm boundary. A page that says “saved” has not changed any domain state by saying so.

A frontend supplies no behaviour. AFUI refuses an override file whose name says it is a script, and reject_frontend_script refuses one hidden inside a template it does supply. The only JavaScript your page loads is yours.

UiPage::builder (the page feature) assembles all three limits for you: the escaping policy, the render, the rendered-markup guard, and a check that every element id your runtime binds to actually exists in the output — a page missing one is reported by that id rather than opened as a window that does nothing. It knows nothing about what your elements or your document mean; it only checks that what you declared is there. A page missing several is reported once, naming all of them: somebody writing a replacement cannot read your contract from where they stand, and finding it one render at a time is the same fix spread across five sittings.

The builder also covers template trees without weakening that boundary. entry_at and template_at separate a stable logical template name from its frontend-relative file path. UiFrontend::files_under plus override_template registers open-ended partial directories owned by an override. requires_element_map derives the runtime’s required ids from the same serializable map exposed to templates, so a Provider need not maintain a second list by hand.

Trusted Provider code may use extend_environment to add plain-value filters and globals. The extension receives only UiPageEnvironment: it cannot add a loader, change escaping or undefined-value behaviour, restore the reserved safe filter, or replace the document global. Filter/global values also cannot be raw MiniJinja Values, so an extension cannot smuggle the engine’s “already safe” bit back to a frontend under another name. Those invariants stay owned by UiPage, including when a page needs locale-aware formatting or another domain-specific projection. A filter that needs to return a sequence or map selected from its input can use UiPageSerializedValue; it rebuilds the data through JSON and deliberately drops engine-only safety provenance.

Errors: what happened, and what to do about it

Error’s Display says what happened. It does not say what to do — that is hint(), and keeping the two apart is what stops a caller with a hint field of its own from printing the same sentence twice, once inside the message and once beside it.

Err(error) => fail(
    &format!("myprovider_{}", error.kind()),
    &error.to_string(),
    error.hint(),
),

A coarser code than that is yours to build, but build it out of named arms rather than a default. A fallback that names one cause — “the frontend could not be read” — reports a closed session runtime and a machine with no browser as a broken frontend, and sends somebody to look at a file that is fine. If you want a fallback at all, let it carry AFUI’s own word through.

kind() returns UiErrorKind, a closed enum with no domain in it, so your own error code is a prefix rather than a match over every AFUI variant. It is an enum rather than a string on purpose: translating a dependency’s failures into your own vocabulary is your job, but you can only do it completely if something tells you when you have missed one, and nothing exhaustively matches a string. A &'static str here forced every caller into a wildcard arm, which is where a classification added later goes to be mis-reported.

match error.kind() {
    UiErrorKind::WindowUnavailable | UiErrorKind::WindowWaitFailed => ...,
    // no wildcard: a classification added to AFUI stops this compiling
}

It is deliberately not #[non_exhaustive], for that reason. Display and as_str() give the stable snake_case word when a prefix is all you want.

kind()as_str()Raised by
InvalidArgumentinvalid_argumentan identifier, URL, TTL, or icon AFUI cannot accept
DeliveryModeInvaliddelivery_mode_invalid--mode or AFUI_DELIVERY named something other than the three words
DeliveryModeNotOffereddelivery_mode_not_offeredthe resolved delivery is one this Provider did not offer
UpstreamNotProxyableupstream_not_proxyablean https:// upstream behind a delivery that proxies plain HTTP
LinkAddressUnavailablelink_address_unavailableno LAN-reachable address to advertise a link on
RuntimeMisconfiguredruntime_misconfigureda second runtime, a zero capacity, or a page that cannot reach its runtime
RuntimeClosedruntime_closedthe session or one of its calls has ended
RuntimeBusyruntime_busya bounded in-process runtime state lock was temporarily unavailable
RuntimeMessageTooLargeruntime_message_too_largeone encoded runtime frame exceeded the fixed wire bound
RuntimeBlobruntime_blobmanaged blobs were not enabled, or bytes violated their declared policy
RuntimePayloadruntime_payloada runtime payload could not be encoded or decoded
FrontendUnreadablefrontend_unreadablea frontend that exists and is trusted but could not be read
FrontendIncompatiblefrontend_incompatiblea frontend written against another ui_api_version
FrontendUnsafefrontend_unsafemarkup or a file name an override may not supply
PageRenderpage_rendera template failed to compile or render
PageIncompletepage_incompletea rendered page is missing an element id or its runtime marker
ConfigUnreadableconfig_unreadableAFUI’s own configuration directory or attention config
WindowUnavailablewindow_unavailableno browser found, or one that would not launch
WindowWaitFailedwindow_wait_failedwaiting on an open window failed
Ioioanything else AFUI could not read, write, or generate

The match behind kind() has no wildcard arm, so a new AFUI variant does not compile until someone classifies it; tests/error_kinds.rs fails if that classification invents a word that is not in this table, and again if a classification in the table is one no error can actually raise.

hint() returns a recovery action or None. Safe mode is suggested only for a failure an override could have caused — a built-in page that will not render is the Provider’s own bug, and sending a person to disable something they never installed helps nobody.

AFUI’s own routes, from inside your page

Every session reserves the whole __afui/ prefix under its own credential. AFUI uses it for the resolved application icon, ending, the typed runtime, and managed blobs. Provider routers must not claim anything below that prefix.

A UiPage render carries the paths a template may render directly as a reserved afui global:

<link rel="icon" href="{{ afui.app_icon_path }}" type="image/svg+xml">

app_icon_path and end_path are relative to the session root, which makes them resolve under the session’s own credential in every delivery. afui is reserved exactly the way document is: an extension that registered that name is refused, because a replaceable layer may not redirect AFUI-owned behavior. From Rust they are APP_ICON_PATH and SESSION_END_PATH, available without the session feature.

The runtime and blob paths are deliberately absent from the template global. Trusted JavaScript uses afui.connect() and its blob methods; Provider code uses UiBlobRef. Neither side names transport paths or builds their URLs.

When your own listener hands out access

The usual shape is one credential per session, minted when you hand AFUI a session and dropped when that session ends. Some Providers have the other shape: a long-lived listener with a domain API of its own that grants access to the same UI on request, and takes it away again.

Do not reimplement credential routing for that. It is the same dispatch, driven from outside:

let mount = UiMount::new("http://127.0.0.1:8080/ui/")?;
let app = provider_routes.nest_service("/ui", mount.router());
let access = mount.external_access(ui_routes, policy, Some(app_icon));

// From your own endpoint, when you decide a request deserves access:
let credential = access.issue(UiExpiry::Idle(Duration::from_secs(1800)))?;
credential.access_url_secret();  // the URL to hand back
access.revoke(credential.secret());

If that mounted page is live, mint the credential and its runtime together:

let (credential, runtime) = access
    .issue_with_runtime::<Action, Reply, State>(UiExpiry::Idle(Duration::from_secs(1800)))?;

Use issue_with_runtime_and_blobs when calls also return managed bytes, or the *_capacities* variants when the default unacknowledged-call and event replay bounds are not the right ones. The runtime is scoped to exactly that credential; revoking, ending, or reaping it closes the runtime and wakes recv. Do not combine issue with a Provider WebSocket or SSE route.

Everything a session gets comes with it: the 404 that does not confirm a UI is here, the Host check that stops a rebound name reaching the same socket, the response headers, the Provider icon route, the reserved __afui/ prefix — and __afui/end, which for these credentials revokes the one it arrived on, since there is no session waiting on an outcome to wake. What stays yours is the part that is genuinely yours: which request deserves a credential, what your endpoint for asking looks like, and what you do with the URL afterwards.

The idle expiry is crash cleanup, not a lifetime a person sees. A process that revokes on its way out never reaches it; one that dies without revoking leaves a credential that stops working on its own. Do not present it to anyone as how long their UI lasts — the delivery decides that.

Holding a credential open from the other side

A process that was handed one of these URLs renews it while a person is still using it:

let lease = UiCredentialLease::new(idle_timeout);
tokio::select! {
    outcome = delivery.wait() => { /* the person finished */ }
    failure = lease.keep_alive(async || my_api.renew(&credential).await) => { /* it stopped */ }
}

keep_alive renews three times per idle window, so one lost renewal is not a lapsed credential, and resolves only when a renewal fails. The call itself is yours: the endpoint that renews a credential is your domain API, and AFUI inventing a wire protocol for your service is not a trade worth making. is_ui_credential answers whether a string is the shape AFUI issues, for a caller reading one back out of a URL — that fact belongs here rather than as “sixty-four hex digits” written down somewhere else.

The floor your page stands on

Every session serves one AFUI-owned stylesheet at BASE_STYLE_PATH. Link it before your own, from the template:

<link rel="stylesheet" href="{{ afui.base_style_path }}">
<link rel="stylesheet" href="style.css">

In that order, because yours loads second and wins at equal specificity; most selectors in the baseline are wrapped in :where() so overriding one is never a specificity fight.

It carries two kinds of rule and no others: what AFUI’s own runtime decides — what a control looks like while a call is in flight, what hidden does — and what is wrong rather than plain when a page forgets it: a focus ring, a [hidden] that beats an author display, a page inset that clears the edges of the phone a link session was opened on, prefers-reduced-motion. There are no components: no button variants, no grid, no utility classes.

It carries exactly one vocabulary, and the line it is on is the same one: afui.connection(el) writes AFUI’s own connection word to data-afui-connection, and reconnecting and closed are dressed here because this page may be showing something that is already wrong is AFUI’s fact and no page should answer it five times. afui.status(el).set(text, state) is the other direction — that word is yours, it lands on data-state, and what “busy” looks like is not AFUI’s to say.

Everything tunable is a custom property on :root, so a different palette is a declaration in your own stylesheet rather than a fork:

Surfaces--afui-page --afui-panel --afui-panel-strong
Text--afui-ink --afui-muted
Lines--afui-line
Emphasis--afui-accent --afui-accent-ink --afui-focus
Meaning--afui-warn-ink/-bg --afui-danger-ink/-bg --afui-ok-ink/-bg
Shape--afui-radius --afui-shadow --afui-font --afui-mono
Layout--afui-page-padding

Every colour has a light and a dark value already; redeclaring one outside a prefers-color-scheme block sets it for both.

--afui-page-padding is how far your content sits from the edge of the viewport: set that one value and each edge takes the larger of it and the area the hardware actually leaves reachable. A page that is edge-to-edge instead — a sticky bar, a full-bleed deck — says so with body { padding: 0 } and places its own insets, because a sticky bar pushed down by a body inset leaves content scrolling past above it.

A route rather than bytes handed to you to serve, for one reason that decides it: an override replaces your stylesheet in full, and a baseline concatenated into that file would go with it. Answered by the session, the floor survives the replacement — which is what lets an override be one template and no CSS at all and still open as a page that belongs here. page_base_style_source() is there for a build that assembles a page outside a session, such as a preview.

The routes a page serves

A page is at /, its stylesheet is a route rather than an inline block, and the override’s assets/ tree is nested under a fixed name so a template can refer to it relatively. That shape is the same for every page here:

page_routes(page, stylesheet, &frontend).merge(my_domain_routes)

For a page rebuilt per request — a live panel, or one reread because the workspace behind it can change while a person is looking at it — route / yourself and merge page_asset_routes(stylesheet, &frontend) instead.

Bytes your page serves itself

A preview, a generated code, an attachment. UiBlobPolicy is a media-type allowlist and a size bound; you supply both, because only you know what your page is for.

let policy = UiBlobPolicy::new(max_bytes, ["image/png", "image/jpeg"]);
policy.respond(media_type, bytes)                     // bytes you already have
policy.respond_with_file(&root, relative, media_type) // one file under a root
policy.read(&root, relative)                          // just the bounded read

Every refusal is a 404, whatever the reason — too large, a type you never listed, a symlink, a path that tried to leave the root. A page asking for something it may not have learns only that it is not there.

respond_with_file never follows a link: relative must be plain names, and every component is checked before the next is appended, so there is no window between resolving and comparing. The bound is applied to the file’s own length and again to what was actually read. read is the same walk without the response, for a page that serves one part of a container it read off disk.

There is no caching, no Range, and no upload here. None of those has been needed by a page in this kit, and each is a second thing to get right in a response a session hands to a browser under a bearer credential.

What your page may load

Every page in this kit denies everything and then names a small set of exceptions. Say which with switches rather than a string:

let policy = UiPagePolicy::new(UiPageScript::Nonce(nonce.clone()))
    .allow_images()
    .allow_form_submission()
    .into_security_policy();

The floor UiPagePolicy::new starts from is default-src 'none' plus a stylesheet from this session’s own origin — no base URI, no framing, no plugin, no form target, and no network destination. Everything else is an addition you name:

SwitchAdds
UiPageScript::Nonenothing; the page has no behaviour and cannot be given any
UiPageScript::Nonce(nonce)exactly one script, carrying this session’s nonce
UiPageScript::SameOriginscript files served from this session’s own origin
allow_images()images from this session’s own origin
allow_fonts()fonts from this session’s own origin
allow_runtime()the same-origin AFUI runtime connection
allow_form_submission()form posts back to this session
allow_inline_styles()inline style attributes and <style> blocks

Assembling rather than formatting is the point. A hand-written policy is a format! over nine directives, where a typo produces a policy that is merely different rather than invalid, and where the HeaderValue::from_str at the end has a failure arm that is very tempting to answer by quietly serving some other policy. That is the one outcome a security header must never have. Here every part is fixed ASCII or a hex nonce, so there is no parse step and nothing to fall back from.

Attaching a UiSessionRuntime to a page built without allow_runtime() is refused when the session is mounted. It has to be caught there: the browser refuses that connection, not AFUI, so nothing on this side would otherwise hear about it and the page would simply sit there connected to nothing.

UiSecurityPolicy::with_content_security_policy remains the escape hatch for a page these switches do not cover. Two things come with taking it. The string is yours, so a mistake in it is your error and must be reported as one — never answered by falling back to a different policy, which turns a broken security header into a page that looks like it worked. And AFUI can no longer tell whether your page may reach its own session, so the runtime check above stops applying to it.

What your page runtime is written in

Roughly seven hundred lines across the pages in this kit were the same work done again: deriving the session root, reconnecting live state, keeping call identity straight, releasing temporary byte bodies, and half a dozen small pieces of care that are only interesting when they are missing.

page_kernel_source() is that, as source to concatenate ahead of your own runtime inside one function scope:

format!(
    "<script nonce=\"{}\">(function(){{\n{}\n{}\n}})();</script>",
    nonce.as_str(),
    page_kernel_source(),
    MY_RUNTIME,
)

It defines one lexical afui binding and writes nothing to window.

The pages in this kit are built out of these:

PrimitiveWhat it is
afui.sessionRoot, afui.urla Provider-owned static or navigation URL kept under this session’s credential
afui.connect({onState, onEvent, onEventReset, onConnectionState})the complete typed session runtime; call sends actions and receives progress/result/error
runtime.blobUrl, runtime.fetchBlob, runtime.releaseBlobAFUI-managed byte bodies without a Provider download protocol
afui.status(element)an aria-live region written as text, with data-state for the stylesheet
afui.connection(element, words)the runtime’s own connection state, marked for the baseline and worded by you
afui.guardUnload(isDirty)warn before leaving, asked at the moment of leaving
afui.place({at, show, hide, onFrame})a second screen inside one page: one history entry per place, popstate in both directions, Escape, and focus returned to whatever opened it
afui.frame({onHead})what the window frame around your page should say about it — title, status — and whether it draws a head at all, so a framed page stops drawing a second one

And these exist, but no page here has needed one yet. They are collected rather than recommended: each was written for a shape that had turned up before, and the shape has not turned up since. Use one if it fits; the reason it is worth saying which is that a table where everything looks equally established is a table that recommends by omission.

PrimitiveWhat it isWhat it is waiting for
afui.inFlight(elements)disabled and aria-disabled togethera control that is not a native form element — an anchor styled as a button. Every page here disables real <button>s and <input>s, where disabled is already complete. AFUI’s own UiDecisionRuntime sets both, so the baseline’s [aria-disabled] rule is not idle
afui.autosave({save, baseHash})debounce, revision, base-hash writeback, and a 409 reported as a conflict rather than an errora field that saves as somebody types. The one page that had it moved to an explicit save control
afui.whileVisible(run, ms)poll only while somebody is looking, and refresh at once on return; stop() is final, and a later tab switch does not undo ita surface that polls. The one that did now receives a live runtime state instead
afui.end()end this session from inside its own page — the browser side of SESSION_END_PATH, so whoever created it sees the same Closed as a shut windowa page with its own “done” control. Today the window, the card and afui session close are the ways out

There is deliberately no location.hash helper. One was here, capped and comma-joined; the only page that needed hash state wanted repeated keys and a different eviction rule, and a primitive that has to be worked around is worse than none — the next person tries it first and then writes theirs anyway.

afui.connection is the one primitive with a vocabulary of its own, so it is worth a paragraph. The runtime’s connection state is connecting, live, reconnecting or closed. Only three of those are ever handed to onConnectionState: connecting is where every page starts, so there is nothing to announce — afui.connection paints it when you call it, and runtime.state reports it. A handler with an else if (state === 'connecting') arm has an arm that never runs, which is what four pages here had to discover one at a time. The word lands on data-afui-connection, which the baseline stylesheet dresses for the two values that mean what you are looking at may already be wrong; the wording stays yours, and a line that also carries your own messages passes no wording at all and keeps its text.

This is not a framework and will not become one. There is no template here, no DOM diff and no state store; every text write is textContent and every element comes from you. A primitive that needed a third-party library would not be collected — it would be declined.

The boundary is unchanged: a frontend still cannot supply JavaScript. This source reaches a browser only because you spliced it into your own trusted runtime. socketUrl, generic request wrappers, raw channels, and SSE helpers are intentionally not exposed: they would recreate a second UI protocol beside afui.connect().

Decide panels: declaration is theirs, binding is yours

If your page returns an answer that moves money, runs a statement, or otherwise cannot be taken back, a replaced template controls the markup — and therefore controls which button is labelled “approve”. Do not solve that by restricting what a template may replace. Solve it by keeping the decision out of the template:

Four rules make that hold up:

What remains is that a trusted template can mislabel a control it declared. That is the accepted model: the person enabled that exact fingerprint, and any later byte revokes it. Encryption of intent is not on offer; what is on offer is that the binding is never up for grabs.

You do not write those four rules again

UiDecisionRuntime generates the script; you own the tag it goes in, because only you know this session’s nonce.

let runtime = UiDecisionRuntime::new("data-myprovider-decision")?
    .with_status_attribute("data-myprovider-decision-status")?
    .with_decision("approve", "approve")?
    .with_pending_text("approve", "Approving…")?
    .with_decision("refuse", "refuse")?
    .with_pending_text("refuse", "Refusing…")?;

let page = UiPage::builder(&frontend)
    .entry("templates/page.html.j2")
    .fallback(BUILTIN_PAGE)
    // The same list, read back as the page's contract.
    .requires_decisions(&runtime)
    .runtime_marker("<!-- myprovider:trusted-runtime -->")
    .runtime(Some(format!(
        "<script nonce=\"{}\">{}</script>",
        nonce.as_str(),
        runtime.source()
    )))
    .render(&document)?;

The declaration vocabulary, the routes, and every word a person reads are yours. AFUI carries no default text and never will: “Approving…” and “Sending payment…” are the same moment in two domains, and a library that guessed between them would be writing your product copy.

requires_decisions is the reason the runtime and the page cannot drift: add a decision to one and the other stops rendering until it catches up. Underneath it are two checks you can also use directly — requires_attribute_values(attribute, values) for one attribute’s vocabulary, and requires_attributes(names) for a contract whose vocabulary is the attribute names themselves. Both match the same way requires_elements does: the whole value, and only on an element a browser would actually render, so a declaration surviving in a comment or inside <template> does not count as a control anyone can press.

At least one carrier per value, not exactly one — a runtime binds every control it finds, so an approve button at the top of a long page and another at the bottom is a page that works.

Partials the Provider has never heard of

A frontend that restructures a page needs partials of its own, and every template has to be registered before an {% include %} resolves:

UiPage::builder(&frontend)
    .entry("templates/page.html.j2")
    .fallback(BUILTIN_PAGE)
    .template("templates/layout.html.j2", BUILTIN_LAYOUT)
    // Everything else the frontend put under `templates/`.
    .override_templates_under("templates", ".j2")?

Templates already named keep their own fallbacks; the rest are registered override-only, so a partial that disappears between discovery and render fails the page rather than silently resolving to a built-in of the same name.

What is shown must be what happens

If the answer commits to something resolved earlier — a payment plan, a quoted fee, a statement — carry that identity in your own code from the thing you stored, never from anything the page could influence. The template renders the plan; it does not get to say which plan.

If your UI reads browser storage

afui session serve frames sessions two ways, and only one of them lets a framed UI touch localStorage.

By default every session is a path on the listener’s own origin and the frames are sandboxed without allow-same-origin. That gives each one an opaque origin, which is what keeps them from reading each other — and what leaves them with no browser storage at all. A third-party client that reaches for storage unguarded refuses to load there.

afui session serve --session-origin-host <HOST> gives every session an origin of its own, s-<credential>.<HOST>. Isolation is then the browser’s own origin boundary, the frame is same-origin with itself, and storage works. It asks something of whoever runs the listener rather than of you: *.<HOST> has to resolve to that machine, and behind TLS the certificate has to cover it. (localhost needs neither — browsers resolve *.localhost themselves.)

So: your UI is not required to avoid browser storage, but a UI that depends on it is one that only works when the person serving it arranged that. Say so in your own documentation, and remember the deliveries that never had the problem — its own window, a direct link, and afui session open.

Your page must rebuild itself from its own URL

afui session serve frames every session in an iframe, and that frame gets torn down and rebuilt whenever its address changes — which happens once, when a lapsed view is reopened: the credential the old address carried was revoked, so the new one needs a new frame, not the old one reused. Whatever your page was showing at that moment — an expanded tree, a half-filled form — is gone, and AFUI cannot preserve it: the frame is on an origin AFUI’s own page cannot read by design (see above), and the shell does not consume Provider payloads. The runtime restores the Provider’s retained current state, but it cannot infer that an expanded disclosure or half-filled local form is domain state. Persist anything that must survive through an explicit typed action; DOM view state is not automatic persistence.

Dropping allow-same-origin from the sandbox to let the shell reach in would remove the isolation that keeps one session’s frame from reading another’s. AFUI will not do that. If view state must survive, make it Provider-owned state with an explicit typed operation and the same validation as any other write.

So the requirement is on you: your page has to be able to reconstruct what it is showing from its own URL alone, the same way a browser tab already has to survive a reload. AFUI’s own contract is the address does not change underneath a session that is still being watched — a session’s frame path is stable across every poll while its view is live or merely warning, and afui session close/window-close remain the only ways a session ends outside your control. The one moment your page has to answer for is being loaded fresh at a new address after its own view lapsed and someone asked for it back.

A second screen inside your page is a place, not a panel

The moment your page opens one record over its list, it has made a second screen — and at the width these sessions are opened at most often, that record covers the list completely. There are then four ways a person can ask to leave it: the system’s own back gesture, the key that closes things, a control you drew, and the navigation control of a window frame your page may be running inside. They have to be one gesture with one result. A page that pushes no history entry loses the first outright: back leaves the whole page from under the record, which on a phone is the gesture people reach for first. A page that pushes one per disclosure loses it the other way, and leaving takes a dozen taps.

afui.place is that one entry and the four ways in and out of it. It renders nothing: show and hide are yours, because whether a place is a pane beside the list or a screen over it is a question about your width and your content. Enter it after the place is on screen and before you write it into the address — what is pushed is a duplicate of where the person already was, so the entry below keeps your page as they left it and your own replaceState writes the place into the new one.

at and show are a pair rather than one “you left” callback because a place is somewhere a person can arrive: going forward again, or opening an address that already names it. at is asked what the current address names; whatever it returns is handed to show, and arriving that way pushes nothing.

Read what at returns off what your page has already been given, not off the address as a name to fetch. An address is a person’s to edit, and a place restored by handing a raw identifier to a typed call is a page asking for something nobody listed.

Do not draw a second head inside a frame

Every page in this kit grew the same header: the program’s name, what the session is on, and a word about how it is going. A framed session then shows it twice — once in the frame’s own head, which already has the first two from the listener, and once again underneath in the page. On the width these sessions are opened at, the second one is a row of the screen spent on nothing.

afui.frame is the ask. onHead(true) means there is a head out there; a page that hears it hides its own and sends up the two things a frame cannot know:

Identity is never sent. Whose program this is and what the session is on are the listener’s word, and a page repeating either into a head it does not own would be a page naming itself in somebody else’s chrome. That is also what makes the rest safe to hand over: a card cannot be made to introduce itself as something else.

afui.place uses the same channel for the one navigational fact — that your page is somewhere with a way back — and a frame with navigation to offer draws the control the person’s window already keeps in that corner, reaching you as onFrame({navigation: true}).

Keep drawing your own head and your own way back. Your page is delivered in its own window and as a direct link as often as it is framed, and in those there is no head but yours. What these callbacks are for is dropping the second one, not having none.

Nothing else crosses. The frame cannot read your page and does not consume what it is showing; it takes a boolean and two lines of text, and the only thing it ever sends back is a request to leave the place you said you were in. Both lines are flattened and capped before they are shown, so they are words and never a lever on the frame’s own layout.

One page, handed over, waited on

Everything above composes, and composing it is nine steps every Provider here wrote for itself. UiSurface is those nine:

let active = UiSurface::<Decision>::new("myprovider", "confirm")?
    .with_subject(subject)
    .with_app_icon(app_icon)
    .with_page_policy(policy)
    .on("approve", |_fields| Decision::Approve)
    .on("refuse", |_fields| Decision::Refuse)
    .with_answer_page(|decision, recorded| decided_page(decision, recorded))
    .merge(my_domain_routes)
    .start(plan, page, stylesheet, &frontend)
    .await?;

let facts = active.facts();
let outcome = active.wait().await?;

A submission arrives as BTreeMap<String, String> and is parsed by a function you wrote. AFUI does not learn what a field means, and this layer will never gain validation, field types, conditional display, layout, or grouping. Anyone who needs those needs a Router, which they have always had. with_answer_page is called with what was answered and whether that answer was the one that counted — false for a click that arrived after the decision, which is the only honest thing to show the person who made it.

This is deliberately the last layer, not the first. Everything it does is reachable directly, and a page that does not fit this shape should use the pieces rather than bend to it.

Testing your UI through AFUI

Add AFUI to your [dev-dependencies] with features = ["test-support"]. It is not in the default set and never reaches a release binary.

use agent_first_ui::test_support::{InstalledFrontend, stub_browser, write_attention_config};

InstalledFrontend::new(&workspace, "myprovider", "console", "1")
    .with_frontend_id("custom")
    .with_file("templates/page.html.j2", MY_TEST_PAGE)
    .install()?
    .trust()?;

trust() computes the fingerprint with AFUI’s own code, through the same function AFUI trusts against. That is the whole point: a fingerprint reimplemented in a consumer’s tests agrees with AFUI today and goes on agreeing right up until AFUI’s algorithm changes, at which point several test suites fail for a reason none of them is about. Keeping AFUI honest about not drifting is AFUI’s job; yours is to prove your UI works.

Drive a live session through AFUI’s real wire with RuntimeClient:

use agent_first_ui::test_support::RuntimeClient;

let mut page = RuntimeClient::connect(&access_url, "integration-page").await?;
let state: Option<MyState> = page.opening_state().await?;
let reply: MyReply = page.call(&MyAction::Refresh).await?;
page.close().await?;

The test supplies only domain payloads. RuntimeClient owns the reserved path, protocol hello, request identity, acknowledgements, JSON envelope, and WebSocket framing. Do not reproduce those details in a Provider test any more than you would reproduce the fingerprint algorithm. Both clients expose call_with_progress; a synchronous process test uses BlockingRuntimeClient so that callback can drive child stdin/stdout between typed progress and the final reply without exposing transport identity.

The rest is what every suite here had written for itself: stub_browser records the URL instead of opening one, so window delivery is testable on a machine with no screen; write_attention_config puts a one-second idle policy where AFUI reads it, instead of waiting fifteen minutes for the default; and wait_for_session / wait_for_no_session poll the registry for a session this process is not the one announcing.

Those last two are for a test that drives a Provider as a child process. In a test that holds the session itself they buy nothing: announcing happens inside attach, before the active session is handed back, so the entry is already there on the line after start. Withdrawal is the direction that is genuinely late — the entry goes when the handle drops — and both helpers block the thread they are called on, so an async test reaches for them through spawn_blocking rather than from inside a single-threaded runtime that the session it is waiting for also needs.

Keep your own end-to-end tests driving the real library. RuntimeClient also drives a real socket; what this removes is the arranging, not the proving.