Agent-First Mail v0.9: Every Legal Combination
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.
One call, every legal shape
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.
| Before | Now |
|---|---|
afmail --workspace DIR push | afmail push --workspace DIR — arguments always follow the command |
afmail -h, -w, any short flag | Long 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 error | afmail 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:
- Two copies of one mail are the same message only if the bytes match. A mailing-list copy that differs by one added header is a second message, not a duplicate.
- Copies that do match are stored once, with several remote locations.
- A
Message-IDseen remotely maps to no particular local message. Don’t index on it.
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:
- Partial progress is kept. A move used to be skipped entirely if any copy of the message already sat in the target, stranding the copy in the inbox; now the remaining locations move and the local location list is rewritten after each one. Flags are persisted per location, so a failure on the second message no longer discards flags already applied to the first.
- Failures that never reached the server stay retryable. An error that provably preceded any remote call is an ordinary step failure now, not the manual-reconciliation
push_remote_outcome_unknownstate. - Conflicting archive config is caught first. If a message’s remote locations belong to source mailboxes configured with different archive steps, planning fails with
archive_action_conflictas a preflight — before a notification or case write lands locally and leaves the message half-moved.
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
config showno longer invents credentials. A secret that was never set printed"***", disagreeing withconfig geton the same key and telling a reader something was configured. It reports null now.skill installships the reference it points at.SKILL.mdsends the agent toreferences/http-api.md; install was writing onlySKILL.mdand dropping the file, handing out a pointer to nothing.afmail --docs | headexits 0. A reader closing the pipe is not a write failure.- Exit 4 is documented, and so are two exit-1 codes that describe the tool rather than the call:
cli_invocation_invalidandoutput_setup_failed. Both are defects to report; retrying cannot help. - Progress stopped lying about relocations. The
pull_mailbox_headers_doneevent hardcodedupdated_location_countto 0 — a relocation is known from the body — so a caller watching progress saw zero all the way to a non-zero total. It’s dropped. - Language tags are checked properly.
workspace.language_bcp47defers to a real BCP 47 grammar, sochineseis rejected at write time instead of being stored and silently falling back toen-US, and privateusex-…tags are admitted. - The registry checks itself. A test drives every declared shape through the same binding
mainuses, with strict argument reads, so a builder arm that asks for an argument its shape cannot supply fails the gate instead of degrading to an empty string in production.
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.