Agent-First Mail v0.9: Every Legal Combination

by Agent-First Kit Contributors

v0.9 compiles the whole afmail CLI from a closed registry — 106 commands, 140 legal argument shapes, one --help call to see all of a command's at once — and hardens what the agent acts on underneath it: a message's identity is now its bytes, a push refuses stale UIDs, and an edit to a file you wrote moves only the keys that changed.

An agent discovers a CLI by asking it questions. Every earlier version of afmail answered those questions from two places: a parser that knew which flags existed, and a pile of hand-written checks that knew which of them could appear together. --help showed you the first. You found out about the second by running a command and being told no.

v0.9 collapses the two. The CLI is now compiled from one closed registry — 106 commands and 140 legal argument shapes — and an invocation runs only when it matches exactly one of them.

A shape is a complete set of arguments that may legally appear together, under a stable id. --help returns all of a command’s shapes at once:

afmail push --help              # push-preview, push-confirm — both, in full
afmail --docs                   # the entire reference, rendered from the registry

That is the whole discovery protocol. There is no recursive mode, no second call to learn what a flag requires, and no combination the parser accepts that the command then refuses. “Mutually exclusive”, “this option needs that one”, and “this command will not run without --confirm” are not runtime checks anymore — they are separate shapes, so an illegal mix is rejected before anything runs and every legal mix is printed.

Deleting the checks deleted a lot of afmail: eleven hand-maintained tables of error hints, a raw-argv scanner, a --log string validator, an allow-list of which commands support --request-id (now simply: the ones that declare it), and the dry-run/confirm guard on push. Each of those was a second copy of a rule the registry already states.

It also fixed things that were only wrong because of the copies. --log startup used to misname the command it was diagnosing, because it identified command tokens against a 65-name table of known words; a command token is now a position in the resolved path, so afmail case review and a positional whose value happens to be review stop being confused.

What this breaks

This is the largest CLI break afmail has had, and there is no compatibility mode — old spellings are gone rather than deprecated.

BeforeNow
afmail --workspace DIR pushafmail push --workspace DIR — arguments always follow the command
afmail -h, -w, any short flagLong names only
--log request,progress--log request --log progress
triage suggestion set ID ... case-add CASE... --action case-add --case-ref CASE
case suggestion set REF ... tag TAG... --action tag --tag TAG
afmail doctor repair then an errorafmail doctor repair --confirm

The suggestion families are the biggest edit for an existing agent. Every action operand is a named argument now, so a suggestion is one flat shape rather than a subcommand with positionals:

afmail triage suggestion set message_inbox_90233_7 \
  --message-review-base-hash <HASH> --reason "refund request, belongs with support" \
  --action case-create --name "Refund request" --group support

Run afmail skill install after upgrading; the skill file carries the new syntax.

A message is its bytes

Underneath the surface, v0.9 fixes what afmail thought a message was.

Until now a message’s identity came from its Message-ID header — a value the sender writes and nothing enforces. Two unrelated mails that reused one Message-ID collapsed into a single local record. Mail with no Message-ID at all fell back to an id derived from the mailbox and UID it happened to be found in, plus a _1, _2 uniquifier.

A message id is now derived from the content of its raw RFC822 bytes. The consequences are worth stating plainly, because an agent has to reason with them:

Because recognizing a duplicate now needs the body, cross-mailbox dedup moved after the fetch — which exposed a real cost. An account with both Inbox and All Mail used to spend a --max-new-messages slot and its bytes on every duplicate and never get them back, so a bounded pull burned its budget on bodies it then discarded. Those slots are refunded now. And pull --dry-run reports candidate_count_is_upper_bound, because a dry run doesn’t fetch bodies and therefore cannot know how many candidates will turn out to be the same mail.

Pushes that stop before they guess

IMAP UIDs are only meaningful under a matching UIDVALIDITY. When a server rebuilds a mailbox, every stored UID silently starts pointing at a different message — or at nothing.

A push now carries the locally stored uid_validity down to IMAP, selects the folder, and aborts with imap_uidvalidity_changed before issuing any STORE or MOVE. Its hint is the recovery: pull, review the new plan, re-confirm. A pull that meets a changed UIDVALIDITY discards its cursor for that mailbox, ignores --since-days there, forces a full reconcile, and reports uid_validity_reset_count and the affected mailboxes rather than quietly skipping mail behind a stale high-water mark.

Three more things a push got right:

Push ids are opaque, and now they say so: a draft’s push id is random per send lifecycle rather than derived from its case and name. Re-sending a draft after a completed send records new sent evidence instead of overwriting the earlier one. Read an id from a plan or from afmail push list; never construct one.

retryable means retryable

A partial push --confirm — exit 3 — used to report retryable: true on the envelope whenever its failures happened to share a retryable code. That directly contradicted afmail’s own rule that exit 3 is not permission to re-push, and it was the top-level field an agent reads first.

The envelope now reports retryable: false on any partial result. Effects that landed stay landed. The field that answers “what should I re-queue” is the per-item retryable inside failures, which is the only place that question has a per-item answer.

Your file stays your file

If you open a contact or a draft and write it the way people write files — a comment at the top, your own key order, role: '' next to organization: "Example Corp", two blank lines above the body — then afmail contact tag used to hand you back the struct’s idea of your file. Every hand-made choice was gone.

Frontmatter edits go through afdata’s document layer now: only the keys whose values actually changed move, and everything else keeps its bytes. contact rename was the last holdout — because a rename changes the file name, the write went to a path that didn’t exist yet and took the create branch — and it now edits in place and moves the file afterwards.

Forty lines that rewrote every valueless key to an explicit null are gone too. They were standing in for a missing trailing newline.

Bcc

Drafts accept --bcc alongside --to and --cc. Bcc addresses stay visible where they should be visible — the local draft, the Review UI, the push preview, and the sender’s private Drafts/Sent evidence — and afmail strips the Bcc header from SMTP DATA while keeping those addresses in the SMTP envelope.

That is the honest version of Bcc, and it’s worth being precise about it with users: Bcc hides recipients from each other, not from you and not from the mail providers in the path.

Under the hood

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

After installing, run afmail skill install so your agent reloads afmail’s updated behavior rules — this release changes command syntax, and an agent working from the v0.8 skill will write invocations that no longer parse.