What changed, and why
The proposal email was good — and almost everything after it was broken. Six of ten message purposes rendered the placeholder "A quick note on this transaction." at exactly the moments the SPEC promises specific copy: the 7-day handoff, the auto-apply FYI, the revision summary, the couldn't-confirm warning, the diverged which-wins question, the archive notice. The payloads those emails needed were computed upstream — the model's clarify question, the diverged summaries, the decided category — then dropped before compose. W2 follow-ups omitted recipients, so when the agent spoke last, AgentMail addressed the handoff back to the agent's own inbox. The auto path never opened a thread, so the first blessed auto-apply crashed its workflow after the YNAB write. And a flagged AwaitingHuman answered every reply with the same canned question, forever.
One fix through one seam: the effect carries the payload, the state machine populates it, the workflow passes it through (and opens a thread when none exists), the activity resolves names and recipients, and compose renders real copy per purpose.
The payload rides the effect
SendThreadMessage gains two optional fields: detail — free text specific to this message (a clarify question, a which-wins comparison) — and decision — what was actually written, so confirms and FYIs can name it.
The effect: payloads travel with the purpose instead of being dropped.
src/ynab_agent/domain/effects.py · 149 lines
⋯ 58 lines hidden (lines 1–58)
⋯ 76 lines hidden (lines 74–149)
The state machine populates them at every emit site. Two examples: the clarify branch carries the model's actual question, and the diverged branch builds the which-wins comparison from the summaries the converge activity already resolved — the signature safety moment finally says what it knows.
Clarify carries the question; diverged carries both sides; NeedsHuman asks a question carrying its reason instead of an empty proposal shell.
src/ynab_agent/domain/state_machine.py · 652 lines
⋯ 403 lines hidden (lines 1–403)
⋯ 132 lines hidden (lines 420–551)
⋯ 64 lines hidden (lines 589–652)
No thread? Open one — the auto-apply crash
A blessed auto-apply commits, verifies, then sends its FYI — but the auto path never proposed anything, so no thread exists, and the send raised ('no thread open yet'), retried ~15 minutes, paged the operator, and killed the workflow after the money write. The fix is one spine-level guard: when a message must be sent and no thread exists, open the thread with that message as its first email. The FYI becomes the thread — which is what makes the SPEC §14.5 'one-reply undo' possible at all.
The fallback covers every future no-thread send, not just the FYI.
src/ynab_agent/workflow/txn_workflow.py · 590 lines
⋯ 273 lines hidden (lines 1–273)
⋯ 42 lines hidden (lines 301–342)
⋯ 231 lines hidden (lines 360–590)
Names, comparisons, and recipients at the send boundary
The send activity resolves the decided allocation to category names (Done — set to Groceries and approved, not 'the category you picked'), derives a which-wins comparison for the plain commit→verify path from a fresh snapshot read, and — the quiet killer — addresses every send to the owners explicitly. Without to=, AgentMail replies to the thread's last speaker; when that was the agent, the handoff/archive/override notices went to the agent's own inbox.
_decided_display names what was written; _diverged_detail builds the comparison for the non-converge path.
src/ynab_agent/workflow/activities.py · 706 lines
⋯ 236 lines hidden (lines 1–236)
⋯ 414 lines hidden (lines 293–706)
Real copy per purpose, and answerable dead-ends
render_body now has a branch per lifecycle moment, with the SPEC §3 copy: the handoff says it's yours now (and that a reply still works); the couldn't-confirm note says to check YNAB; the archive notice asks for a category or 'handled'; the FYI names the category, the standing rule, and the undo. A sweep test renders every purpose and asserts the placeholder never appears.
Every purpose has copy; the placeholder survives only as the fallback for an unmapped future purpose.
src/ynab_agent/agentic/compose.py · 286 lines
⋯ 202 lines hidden (lines 1–202)
Finally, interpret accepts a missing proposal. A flagged verify-failure entry or a NeedsHuman wait has no proposal; the old code answered every reply with the same canned question — an infinite loop. Now a reply that names a category commits it, and only a bare "ok" (nothing to approve) asks.
Approve-with-nothing-to-approve asks; a named category commits — flagged entries are answerable by email.