Agent-First Mail v0.10: Not Our Window

by Agent-First Kit Contributors

v0.10 hands the human review window to agent-first-ui: afmail deletes its own listener, request parser, and browser launcher, and gains what it never had — four independently replaceable review surfaces, a frontend contract with a version on it, and doctor checking the UI a person will actually see. Credentials in config move to one key with the AFDATA source grammar.

afmail has always had one job the rest of the toolchain doesn’t: stopping, showing a person a rendered mail, and waiting for a decision. Doing that meant afmail owned a small web server — a TcpListener loop, a request parser, a urlencoded form decoder, a Chromium launcher, a profile guard so the browser never touched the user’s real profile.

None of that is mail. All of it was afmail’s to keep correct.

v0.10 hands the window to agent-first-ui. review.rs goes from 2379 lines to 1883, and the host code afmail is responsible for shrinks by 171 lines net — while the review surface gains things it could not previously afford.

What a review still is

Nothing about the contract with an agent changed. A review is still a blocking call that opens a window, waits for a human, and returns one typed result. submitted still means an answer, cancelled still means an explicit refusal, and a triage mutation still executes only after a complete submitted result. The domain properties survived the extraction intact; the transport underneath them is what moved.

The one-form-per-page action= post is gone in favor of typed routes, which is invisible from the outside and is the reason the urlencoded decoder could be deleted.

Four surfaces, four names

afmail has four review surfaces: triage, case, notification, draft. They used to share one identity, because there was nothing to distinguish for — one process served all four out of one listener.

They now carry four ui_kinds: triage_review, case_review, notification_review, draft_review. That matters because ui_kind is the key a replacement frontend is looked up by. One shared name would have meant a person replacing the triage UI silently replaced the draft editor too — with a frontend that knows nothing about drafts.

Your Review UI lives somewhere else now

Until v0.10, replacing the Review UI meant putting HTML under the workspace’s templates/ tree, next to the Markdown templates. Two very different kinds of override shared one directory, one lookup, and one trust story — which is to say, one of them had no trust story.

A custom Review UI is now an AFUI frontend:

.afui/frontends/afmail/<ui_kind>/
  frontend.json
  templates/
  assets/

Create it with afui frontend init. It is inert until afui frontend enable trusts its exact contents, and editing it revokes that trust — so re-enable after every change. That is the property the old arrangement could not offer: HTML that afmail serves into a human’s browser, containing whatever a template can contain, was being picked up from a directory anything could write to.

frontend.json declares the contract version it was written against:

{
  "frontend_id": "my_mail_review",
  "ui_api_version": "1"
}

afmail implements Review UI API version 1 — the typed render document, the template keys, the trusted-runtime marker, and the data-afmail-* / data-copy-* attributes the runtime reads. A frontend declaring any other version is refused rather than rendered against a contract it doesn’t share.

Every file in a frontend is optional. Anything you don’t supply falls back to afmail’s built-in pack for the active language, so a frontend that replaces only review/page.html.j2 keeps localized messages and editors in all nine languages. afmail render templates seeds frontend directories that already exist with the built-in Review UI to start from; it never creates a frontend and never overwrites what’s there.

Content templates — mail, drafts, notes, contacts, generated Markdown — stay exactly where they were, under workspace templates/. The rule is now one override point per tree: neither tree can serve the other’s templates, so every template has exactly one place it can be replaced from.

For agents: two error codes are about the user’s frontend, not an afmail fault. review_frontend_unreadable and review_frontend_incompatible mean the installed UI is broken or version-mismatched. Fix the frontend, or rerun with AFUI_SAFE_MODE=1 to open the built-in UI. Neither is a reason to stop reviewing. Run afmail skill install after upgrading; the skill file carries these rules.

expired is not dismissed

A LAN review hands out a URL. If nobody opens it before the plan lapses, afmail used to report dismissed — the same outcome as a person opening the window and closing it without answering.

Those mean opposite things to whoever reads the result. “A human looked and declined to answer” is a signal; “nobody was there” is not. A lapsed plan now reports outcome: expired, and offering the same review again is fine. dismissed keeps its narrower meaning: closed, by someone, without an answer. Cancel is recorded as a completed decision rather than a closure, for the same reason.

doctor checks the UI you’ll actually see

Smoke-rendering templates is only useful if it renders the templates that will serve. afmail doctor now resolves the frontend for each ui_kind and renders through it, falling back to the built-in pack where no frontend is installed.

A frontend that fails to load is reported as its own issue against its ui_kind, not propagated — so one unloadable frontend does not hide everything else doctor has to say. The point is to hear about a broken review UI at doctor time, rather than the first time a review command tries to open a window on it.

One key for a credential, and it says where the credential is

Configuring an IMAP password meant choosing between two keys: imap.password_secret for the literal value, imap.password_secret_env for a variable name holding it. Two keys, mutually exclusive, with validation to enforce that they never both appear, plus a hard error if you put an env:-looking string in the literal one telling you to go use the other key.

There is one key now. imap.password_secret and smtp.password_secret take the AFDATA source grammar:

afmail config set imap.password_secret "env:AFMAIL_IMAP_PASSWORD_SECRET"
afmail config set imap.password_secret "file:/etc/afmail/creds.json#imap.password_secret"
afmail config set imap.password_secret "literal:env:this-really-is-the-password"

The value, or where to read it. literal: is the escape hatch for a password that genuinely starts with a scheme prefix — previously a hard error that told you to use a second key. Switching sources is a set on the key the secret lives at, not a move to a sibling.

Two rules narrow the grammar for a config file:

Validation parses the source without reading it. config set runs on whoever’s machine is doing the editing, and whether that machine has the variable set says nothing about whether the machine that connects will. The read happens where a connection needs it.

afmail api serve --access-token-secret takes the same grammar.

What this breaks

Install

brew install agentfirstkit/tap/afmail        # macOS / Linux
scoop bucket add agentfirstkit https://github.com/agentfirstkit/scoop-bucket
scoop install afmail                          # Windows
cargo install agent-first-mail                # any platform

Run afmail skill install after upgrading so your agent picks up the expired outcome, the frontend error codes, and the new location of Review UI overrides.