Architecture Case Study Live in production

TM Go

A tour-management agent for independent bands. It advances shows over email and SMS, reads free-form venue replies, pulls out the logistics, and keeps each tour's records in sync. This write-up covers the system, the seven engineering decisions that made it safe to write to a band's real data, and where it goes next.

LangGraph
Bounded state-machine agent, not an open loop
32
Adversarial email-chain cases; 100% on both providers
2
LLM providers with automatic failover
59
API routes; Pydantic-validated, containerized
Overview · 01 Interactive architecture

See it two ways: the loop today, and the refactor

Toggle between how it works now (the ReAct loop and its ~25-tool belt) and the refactor (a bounded node graph). The graph is live. Click any node to see what it does, and the amber trail animates the real "advance a show" path.

Open the diagram full-screen →

01 System & stack

System overview

The real deployed system, with prototype shortcuts named as such rather than hidden.

FrontendReact + Vite, deployed on Vercel
APIStarlette, now FastAPI. 59 routes, with Pydantic-validated AI endpoints and OpenAPI docs
Agent coreLangGraph StateGraph: a router dispatching to explicit, deterministic pipelines
LLMLangChain init_chat_model, OpenAI primary with Anthropic failover
SMSFastAPI + Twilio webhook
DataGoogle Sheets as each band's system of record, plus Supabase Postgres for auth, storage and AI telemetry. Postgres is also the migration target: venues and email state are dual-written behind a parity gate, with cutover on TMGO_STORE=db
SchedulerRailway cron worker for reminders
Reliabilitytraced_llm observability, closed-loop evaluation, and an /api/ai-health dashboard
DeliveryGitHub Actions CI (build, lint, eval gate) and Docker, with Vercel/Render auto-deploy
Deliberately not included: billing. There's no Stripe yet. The API started as Starlette and moved to FastAPI specifically for the AI endpoints. Naming what isn't there is part of the point.
Decision 01 Agent design

A bounded state-machine agent

The problemAdvancing a show is a multi-step workflow where correctness matters. Writing an incorrect load-in time into a band's real tour data is worse than writing nothing at all, so the agent's behavior has to be predictable.
Naive approach

A ReAct agent that reasons and picks tools freely each turn.

It re-plans every turn, its tool order is non-deterministic, and it can skip steps. That's fine in a demo and unacceptable in a workflow that writes to real data.

What I shipped

A hand-authored LangGraph StateGraph. A router node classifies intent once, then dispatches to explicit pipelines. The advance pipeline, for instance, reads the venue and drafts the email.

The LLM only drafts prose and parses free text into JSON. Routing, sheet I/O, validation, and email transport are deterministic Python.

The sharpest edge: the router has no send intent at all. Sending isn't something you route to. It's the resume of a paused draft after a human approves it, so "send it now!!" has nowhere to land.

demo/advancing/golden.yamlrouter golden set
# There is NO "send" intent. Sending is the resume of a paused draft,
# so no golden case can route to it.
{id: advance_plain,  query: "advance The Fillmore",        expected_intent: advance}
{id: reply_paste,    query: "they replied, here's the reply",  expected_intent: parse_response}
{id: inbox_heard,    query: "did they reply yet?",           expected_intent: check_email}

# Safety: an enthusiastic "send it" must NOT reach a mutating action.
{id: bare_send,      query: "send it now!!",                 expected_intent: chat}

That last case is the one I'd point to in a review. It passes because the capability is absent from the router's vocabulary, not because a prompt discourages it.

PrincipleDeterminism where correctness matters, the model only where language matters. The right move wasn't a more capable agent. It was a more constrained one.
Decision 02 State management

Three state machines, kept orthogonal on purpose

The problemA show has independent truths running at once. Is this show happening? (a booking negotiation). Do we have the logistics yet? (an advancing workflow). Whose turn is it right now? (a live conversation). Collapse them into one status column and you get nonsense states: a cancelled show sitting at "50% advanced," or a confirmed show that looks untouched because someone overwrote the field.
Naive approach

One status column per show, written by whoever touched it last.

Unrelated processes fight over one field and it drifts. A reply arrives, the column still reads sent, and the interface reports the wrong state with full confidence.

What I shipped

Separate machines, split by where the truth comes from.

Booking runs prospecting → hold → confirmed → cancelled. It's a human decision that exists nowhere else, so it's stored as a fact.

Advancing runs not_started → partial → complete. It's fully recomputable from the row, so it's derived: a pure function of tracked and missing fields.

And a gate between them: advancing only runs when booking is confirmed.

The third machine is the subtle one. What the inbox renders (Waiting, Follow-up due, Reply received, Done) isn't advancing progress. It's conversation state, computed from the messages and the clock. That second input makes it different in kind, because it changes with no write at all. A venue goes from "waiting" to "follow-up due" three days after a send. Nothing happened: no reply, no click, no event. Time passed.

Why it can't be a column

A stored copy of a time-dependent value is wrong by default and right only by coincidence. Keeping it true would take a cron job racing the clock, which is a background process whose whole job is papering over a modeling mistake.

So it's computed at read

A send older than 3 days with no reply escalates to follow-up due and surfaces in Needs Action. No writer, no drift, no catch-up job.

It reads timestamps, which is why the two-clock bug produced wrong urgency rather than just ugly dates. More on that in The state engine.

Principle"Derive everything" is the wrong lesson, and it's the one I'd have given a year ago. The rule is store what only a human knows, derive what the data implies. The three machines sort themselves by where their truth comes from: booking from a person, advancing from the row, urgency from the clock. Keeping them on separate axes is what stops one from overwriting another. A cancelled show cannot be half-advanced, because advancing is gated on the booking fact rather than tangled into it.

The escape hatch, stated plainly: a user can still set advancing status by hand. Four options, not the six the system computes, because showing every internal state read as overlapping choices. A pure derivation with no override is elegant, and it loses to reality the first time a venue confirms something over the phone.

Decision 03 Inbox modeling

The inbox as a workflow queue

The problemSend outreach, receive replies, convert replies into structured facts, and generate the next email, across dozens of venue threads at once.
Naive approach

Build an email client. That merges three representations of one conversation: live IMAP, logged sends, and optimistic UI state.

They disagree. Messages reorder, duplicate, and deleted ones come back.

What I shipped

Reframed as a queue keyed on the show, not the message. Each cycle reconstructs "what to ask" from (questions enabled) − (facts on sheet).

The system never relies on remembered conversation state. It recomputes what's still outstanding.

PrincipleCollapsing three sources of truth into one derived view eliminated the entire ordering-and-duplication bug class.
Decision 04 Reliability

Reliability as a design requirement

Each of these exists because a specific failure would place an incorrect fact in front of a band on tour. They are requirements, not polish.

Provider failover

OpenAI primary, Anthropic on failure. One provider's outage isn't the product's outage. The golden set is scored on both, so failover is a measured equivalence rather than an assumption.

Tolerant parsing

JSON extraction survives code fences and stray prose, so a formatting quirk doesn't sink a valid extraction.

Human-in-the-loop

Extractions are reviewed before reaching the sheet, and they gap-fill only. A confirmed value is never overwritten.

Validated inputs

A malformed request returns a clean 422 rather than a KeyError several calls into the stack.

How the email path is actually tested

Email is where this product lives, and it's the layer with the most ways to fail quietly. Nobody files a bug for replies that never arrive. So it carries the most tests, 59 across seven suites, and most of them are about telling apart states that look alike.

IMAP IDLE is the clearest example. The protocol is full of lookalikes, so each distinction gets pinned:

test_email_watcher.pytransport, 8 tests
def test_exists_means_new_mail():
    conn = FakeConn([b"+ idling", b"* 7 EXISTS", b"A001 OK IDLE done"])
    assert ew.idle_once(conn, 1) is True

def test_quiet_timeout_is_not_mail():        # silence != a reply
    conn = FakeConn([b"+ idling", "TIMEOUT", b"A001 OK IDLE done"])
    assert ew.idle_once(conn, 1) is False

def test_keepalives_ignored_until_exists():   # EXPUNGE/RECENT are noise
    conn = FakeConn([b"+ idling", b"* 3 EXPUNGE", b"* 2 RECENT",
                     b"* 9 EXISTS", b"A001 OK"])
    assert ew.idle_once(conn, 1) is True

def test_idle_refused_raises_unsupported():   # fail loud, not idle forever
    conn = FakeConn([b"A001 NO IDLE not allowed"])
    assert raises(ew.IdleUnsupported)

The rest of that suite covers a server BYE raising a connection error, backoff that doubles and caps, and the one I care most about: an exception in a handler never kills the watch loop. One bad message must not silently end email ingestion.

Identity (8 tests)Which venue owns this message? The precedence is explicit and tested. An explicit binding beats everything, a sent thread re-attaches after a re-upload, watch_since blocks mail that predates the venue, a not-yet-started venue collects nothing, unrelated mail stays untagged, and fuzzy matching runs only when asked for.
Comprehension (32 golden chains)Adversarial threads: multi-show bleed, deal-term soup ("$500 against 80% of the door, settled by midnight"), mid-thread personnel handoffs, negation traps. 10 carry negative controls. Broken down in When the model breaks.
Triage (before the model)Bounces and out-of-office replies are caught by signature matching in code and never reach the LLM. Everything else is classified, and a problem verdict can't write updates.
Decision 05 Evaluation & observability

Measuring correctness, and learning from corrections

Online, a closed loop

The human review step logs every accept, edit, and reject. Each correction is a labeled failure, which gives me a live dataset of the agent's weak points at zero labeling cost. Live misses get promoted into the golden set, so the harness grows from real traffic rather than my imagination.

Offline, a CI gate

A golden-set harness scores field extraction and fails the build below 80%. It now holds 32 email-chain cases, 10 of them with negative controls, so invented answers cost score and abstaining beats guessing. It reruns weekly on a schedule, because model drift doesn't wait for a commit.

One tracing seam

traced_llm is the single point every model call passes through. It records endpoint, model, latency, tokens, estimated cost, and whether failover fired.

100%
Golden set, both providers
59
Unit tests, seven suites
32 / 10
Chain cases / negative controls
Weekly
Scheduled eval rerun

The flywheel: a button in the UI becomes a test case

The set grows because the product collects failures, not because I sat down and imagined them. Every human touch is already a label. An accept is a pass, an edit hands over the ground-truth value, a reject is a hard negative, and the ⚑ flag button captures the reply as a ready-made thread.

① Collect (automatic)Flags and edited or rejected extractions land in the observability store with the original text. An edit's final_value is the right answer, already typed by someone who knew it.
② Shape (scripted)promote_evals.py turns those into properly-formed cases in candidates.json.
③ Promote (human)A person moves the keepers into golden_extra.json, which the harness merges into the golden set. Collection is automatic, promotion is a reviewed commit. Auto-promoting would let one mislabeled correction redefine "correct" for every future run.
Why this shapeMost eval sets die of neglect. They get written once, then quietly measure a product that has moved. This one is fed by usage, so the cases that arrive are the ones reality is generating, weighted by how often they happen. The failure I'm designing against isn't a bad case getting in. It's the set going stale.

Monitoring drift I didn't cause

A pinned model name is not a pinned model. Providers ship RLHF passes and silent revisions, so a prompt gets tuned against behavior that then moves underneath it. Extraction is unusually exposed here. What I need from the model is terseness and abstention ("omit any field the venue didn't address"), while assistant tuning generally pushes toward being more helpful and more complete. In this context, that is the hallucination mode.

What catches it today

The weekly rerun, which is the only mechanism that fires when nothing in my repo changed. A push-triggered eval structurally can't see drift, because drift arrives without a commit.

Scoring both providers also tells the two apart. If one moves and the other doesn't, that's a provider event rather than a prompt regression.

What's still missing

Per-case history. Today I get a pass/fail number. I want a time series per case, so a drop points to a specific date and a specific case, and a model update reads as a step change.

Alerting on delta rather than threshold. A set sliding from 100% to 94% to 88% never trips an 80% gate, and that gradual shape is exactly what drift looks like.

Two properties I'd defend beyond the obvious, each fixing a way evals lie:

Grades the real promptThe system prompt is a pure function, so the eval scores the exact text production uses. The most common way an eval quietly stops meaning anything is that it tests a prompt nobody ships.
Punishes inventionHallucinated fields count against the score, not just missed ones. Under a misses-only metric, a model that guesses at everything outscores one that correctly abstains, which rewards exactly the behavior that hurts a band on tour.
In one sentenceThe agent is instrumented end to end, with per-call cost, latency and failures visible live, and every human correction becomes labeled evaluation data that gates deployment.
The gap I'd name first: extraction has 32 cases and generation has 2. Judging a drafted email needs an LLM judge with a rubric, which is slower and costlier to build than substring checks, so the cheap half got built first. That's sequencing, not balance.
Decision 06 Data model

Migrating off the spreadsheet without a big-bang cutover

Why Sheets was right to start

Google Sheets is each band's system of record. Bands see and edit their own data with zero setup: no accounts, no import, no training. For proving out the workflow, nothing beats a tool the user already opens every day.

Where it breaks

API quotas, no transactions, no concurrency guarantees. Two writers collide and the last one silently wins. There's no row lock to reach for.

I named that ceiling before hitting it, and the exit is now underway.

The hard part isn't Postgres. It's moving while the thing is running. A band on tour can't have a maintenance window. So the migration runs as a strangler fig, gated by a measurement, in five reversible steps.

① Shadow writeEvery venue write goes to Sheets and Supabase. Sheets stays authoritative and Postgres just watches. Zero user-visible change, so if the new path is broken, nothing breaks.
② BackfillA script walks the existing sheets into Postgres, so the history is there rather than only writes made after the cutover moment.
③ Parity gateThis is the step that makes it safe. /api/store/parity reads both stores and reports a match percentage plus the exact diffs: rows only in the sheet, rows only in the database, and field-level mismatches. Cutover requires 100% during the shadow window. It's a number rather than a judgment call, and when it disagrees it names the row and the field.
④ Flip readsTMGO_STORE=db, or per-request ?store=db, moves reads to Postgres. It's reversible in one environment variable, with no redeploy and no data movement. Rollback is the same switch.
⑤ Demote to exportSheets becomes a view. An export endpoint keeps the band's spreadsheet current, so they keep the tool they like and it stops being what correctness depends on.
PrinciplePicking the right tool for the stage, and naming where it will fail before it does, is the judgment rather than a gap in it. The follow-through takes the same discipline: a migration you can't measure isn't a migration, it's a hope with a deadline.
Status: shipped for venues and email state, with dual-write, backfill, parity and flag-gated reads all live. The remaining domains, contracts among them, are still Sheets-only. The pattern is proven, the coverage isn't complete.
Decision 07 Delivery

Evaluation-gated continuous delivery

CI on every push

GitHub Actions runs the frontend build, a real-error lint, and the evaluation gate. A regression in extraction accuracy blocks the merge.

Automatic deploy

A passing main deploys itself to Vercel and Render, so there's no manual release step to forget.

Containerized backend

Docker packages the backend to run identically anywhere. This container is the unit a worker pool would replicate at scale.

Why it mattersBecause the backend is already containerized, moving to a worker pool is a deployment change rather than a rewrite. The scaling path was designed in from the start.
Depth · 01 Where the intelligence lives

Two LLM seams, and everything else is code

The model appears at exactly two points, both of them language problems: parsing a venue's free-form reply into typed fields, and drafting the outbound message. Routing, scheduling, sheet writes, validation and retries are all deterministic Python. That placement is the load-bearing decision the other six hang off.

What this costs me

Flexibility. A new capability means writing a pipeline rather than prompting the agent to improvise one. The system can't handle a workflow nobody designed. That's a real limitation and I'd say so plainly.

If the product needed open-ended exploration, something like "figure out this venue's whole booking process," this shape would be wrong. I'd add a bounded free node: one agentic step with a budget, a timeout and typed output, sitting inside the deterministic graph.

What it buys

Verifiability. Every path is enumerable, so every path is testable. That's what makes the CI gate and the feedback loop possible at all. You can't regression-gate an agent whose control flow differs every run.

Blast-radius control. The model's worst output is a bad draft or a bad parse, and both sit behind a human gate. It structurally cannot send, write or delete.

Tied togetherAll seven decisions are one bet made seven times: trade flexibility for verifiability wherever the system touches real data. The orthogonal state machines, the queue view, the eval gate, the parity-gated migration. Each is that same trade in a different subsystem. They don't contradict each other, they compound.

The shape underneath all of them is a decision about where a truth is allowed to come from. The model proposes language, never facts. A projection is computed in one place. Booking status comes from a human because nothing else knows it. Cutover comes from a parity number rather than confidence. Put that way, "constrain the agent" stops being a safety slogan and becomes the same rule the data model already follows: one source per truth, and name it.

Depth · 02 Failure modes & options

Where it breaks, where it doesn't, and the options when it does

Each subsystem's break condition, with the mitigation shipped today and the options held in reserve. The model's own failure modes get their own section, When the model breaks.

ExtractionBreaks when: a venue writes something genuinely ambiguous ("load in around when doors-ish"). Today: human review catches it and the correction becomes labeled eval data. Options: per-field confidence with abstention, dual-model consensus on low-confidence fields.
Sheets I/OBreaks when: quota bursts, or two writers collide with no transaction to protect them. Today: the real fix is in flight. Venue facts and email state dual-write to Postgres, a parity endpoint proves the stores agree, reads cut over behind TMGO_STORE=db, and an export endpoint starts the demotion to a report. Left: the remaining domains, then contracts.
Email ingestBreaks when: IMAP polling lags, or a cold start misses the window. Today: because the queue is derived, a late-seen message resolves on the next cycle. It's delayed, never lost. The event-driven path is half-built: a Gmail push receiver acks fast and hands the work to the job queue. Left: GCP pub/sub and watch enablement.
LLM providersBreaks when: the primary degrades. Today: automatic failover, recorded per call by traced_llm. If both are down: the graph degrades to queue-and-notify. Drafts wait, nothing silently drops, and the deterministic paths like reminders and sheet sync keep running.
StateWhere it doesn't break: the three machines sit on separate axes. Booking is stored because only a human knows it, while advancing and turn-urgency are derived from the row and the clock. No two writers contend for one field, and a derived value can't desync from its source. Breaks when: a derived value depends on inputs that themselves disagree. That's exactly what the two-timestamp-format bug was, and why one UTC format at the API boundary was the fix rather than patching the projection.
WritesWhere it doesn't break: nothing the model produces reaches a band's data without an approval click. The failure mode "the AI corrupted my tour sheet" has no path through the graph.
Quality driftBreaks when: a prompt or model change quietly degrades extraction. Today: my changes can't ship past the CI eval gate, and the provider's are caught by the weekly rerun (see Evaluation). Options: multi-trial pass^k, paraphrase perturbations, per-case history.
The honest one: single-run evals measure capability rather than reliability, and an agent can pass once and still be flaky. Capability says it can work. Consistency says it keeps working.
Depth · 03 Token economics

Cost as a designed number, not a surprise

One advance cycle touches the model exactly twice, to parse the reply and draft the response. So cost scales with shows advanced rather than chat length or agent wandering. That's the bounded graph paying out: no re-planning loops means no unbounded spend. The levers, in the order I'd pull them:

Metering firsttraced_llm records tokens and cost per call, per provider. Every lever below is verified against that meter rather than assumed.
Model tiering (shipped)Light classification and summaries run a small model. Extraction went on a larger one, because grounding errors there reach real tour data. Then I measured that assumption, and it didn't survive. See below.
Response caching (shipped)The gateway caches deterministic calls only, meaning temperature-0 routing, parsing and extraction. The same prompt inside the TTL returns the same answer for zero tokens. Drafting runs warm and is never cached, because a fresh draft should read fresh. Caching only pure functions of input can't add a failure mode.
Context budget (next lever)A recency window plus hard caps on history. Without one, every turn's cost grows with transcript length, which is the quiet leak in most chat products.
Per-tenant budgets (shipped)A per-band daily token ceiling, enforced before the call with usage recorded after, so one runaway tour can't eat the month. A blown budget deliberately does not trigger failover. Both providers cost tokens, so the answer is "not today," not "try the other model."
PrincipleStrategic cost design is architecture rather than discounts. Bound the loop, tier the models, cache the constants, budget the context, and meter everything so each lever's payoff is a measured number.

Which model should be making the call? I ran the experiment.

"Tier your models" is advice everyone repeats and nobody measures. So I built a bench, scripts/model_cost_bench.py. It scores the same 32 golden chains with the production prompt lifted straight out of the running code, across three tiers, recording accuracy, cost and latency in one pass.

Accuracy

32 golden chains, incl. negative controls
Haiku 4.598.4%
Sonnet 4.698.4%
Opus 4.698.4%
Identical. 31/32 perfect on all three.

Cost

USD per 1,000 extractions
Haiku 4.5$1.04
Sonnet 4.6$2.96
Opus 4.6$5.13
4.9× spread, top to bottom.

Latency

p50 seconds per call
Haiku 4.51.02s
Sonnet 4.61.87s
Opus 4.63.24s
p95: 2.16s · 3.60s · 4.68s.
The result5× the price and 3× the latency bought zero accuracy. On this task the biggest model is strictly worse: the same answers, slower, at five times the bill.

The more useful finding is which case they all miss. It's the same one for every tier, and it isn't a hard extraction. It's an out-of-office auto-reply, and every model helpfully pulls the vacation forwarding address out as the venue contact. The case forbids exactly that.

scripts/run_evals.pythe case all three tiers fail
# A negative control: the correct answer here is NOTHING.
"venue": "The 40 Watt",
"thread": [
  {"from": "bot",   "text": "Load-in time?"},
  {"from": "venue", "subject": "Automatic reply: Advancing",
   "text": "I'm out of office until Aug 20 with limited email access."
           "For urgent booking matters contact jamie@40watt.com."}
],
"expect": {},                          # no fields should come back
"forbid": ["loadIn", "contact"],       # and these two are traps
"expect_kind": "auto_reply"

# Every tier returns: {"contact": "jamie@40watt.com"}  ->  scores 0.5
What money doesn't fix

Structural, not a capability gap. Every tier fails it identically, because being helpful with a stray address is what these models are trained to do. Opus fails it at 5× the cost.

What does: a dozen lines of code

In production that email never reaches a model. _deterministic_reply_kind() matches the auto-reply signature and short-circuits first.

api_routes.pyruns before any LLM call
def _deterministic_reply_kind(from_addr, subject, text):
    """Machine-signature replies, caught in CODE. No LLM, no ambiguity."""
    if ("mailer-daemon" in fa or "postmaster@" in fa
            or "undeliver" in sub or "550 5.1.1" in tx):
        return "bounce"
    if ("automatic reply" in sub or "out of office" in sub
            or "auto-submitted" in tx[:200]):
        return "auto_reply"
    return ""   # empty means: let the model classify it

The bench deliberately bypasses that layer to isolate the model, which is what makes it the cleanest evidence I have that the layer earns its place.

The buying ruleSpend on the model only where the model is the constraint. Here it isn't. Accuracy is flat across a 5× price range, and the one real failure is answered by code rather than a bigger checkbook. The money that would have gone to Opus buys nothing, while an afternoon spent on a triage function bought the only point on the table.
Limits worth stating. This run covered the Anthropic tiers on a 32-case set, single-pass, so a one-case swing moves the number 3 points. And three models tying may mean the set can't separate them, which is a finding about my eval rather than about the models. The honest read is "no evidence the big model is better here," not "proof it couldn't be."

Then I ran the same test on the provider we actually pay

The bench above graded the failover side. The primary is OpenAI, and production sets EXTRACT_MODEL to gpt-4o because extraction touches real tour data and the expensive model felt like the safe choice. Same 42 golden chains, same production prompt, same scorer, both tiers:

The resultgpt-4o costs 17.3× what gpt-4o-mini costs, scores exactly the same 100%, and isn't even faster (0.86s vs 0.97s per call, which is inside the noise). The expensive tier is buying nothing here except the feeling of having chosen it.

That's a live config change worth making: extraction can move to the cheap tier and the eval gate is what makes the downgrade safe rather than a gamble. The full graphic, generated from the run data:

Open the comparison full-screen →

Why the comparison is half-finished, stated plainly: the Anthropic row in that graphic reads unavailable, because the account hit its credit limit during this work. It renders as a blank row rather than a zero, which matters: an earlier version of this harness scored unreachable models as 0.0 and produced a confident, entirely false result. A cross-provider head-to-head on one identical case set is still owed.
Depth · 04 The state engine

Records, events, projections, and why the views can't disagree

Decisions 2 and 3 generalized into the engine now running the inbox. Every state bug we hit had one root cause: a count saying 1 while the list said 0, a deleted venue leaving a ghost, old emails re-tagging a re-uploaded venue. In each case, the same truth was derived in more than one place. The fix is a model rather than a patch.

① RecordsThe facts, meaning a venue's row. This is the source of truth: Sheets today, Postgres next.
② EventsAn append-only, timestamped log of what happened: sent, received, accepted, edited, and deleted as a tombstone rather than an erasure. Each carries an idempotency key.
③ ProjectionsEvery "state" the UI shows is a pure function of records plus events, computed in exactly one place. Chat and buttons read the same projection, so they cannot disagree.
The bug this killed (shipped fix)

Delete a venue, re-upload it, and old emails re-tagged the new one. Two matchers in two endpoints, with no time bound.

The fix, by construction: one shared matcher (_match_email_to_venue), a watch_since stamp at creation, and a time bound (_watch_allows) so a reply only counts if it postdates the venue. A re-upload is a new aggregate with a fresh cutoff, so prior events can't resurface.

The invariants (each maps to a bug it prevents)

1 · one reducer per projection  2 · idempotency keys (message_id, content-hash)  3 · time-bounded projections  4 · tombstone deletes  5 · single-writer per aggregate with optimistic concurrency  6 · read-your-writes for the actor, eventual for pollers.

Every mutation goes through one gated apply(event), whether it came from chat, a button or an inbound email. It's idempotent, atomic (best-effort on Sheets, a transaction on Postgres), and it emits which projections it invalidated. Cache invalidation is an output of the write rather than a cron job hoping to catch up.

test_inbox_tagging.pyidentity precedence, 8 tests
# Each test pins one rule about which venue owns a message.
def test_matcher_tags_fresh_mail_by_subject():
    assert tag(TK, em(), ..., VENUES, EMAILS, set(), {}) == "Bowery Ballroom"

def test_watch_blocks_old_mail_from_matcher():   # the re-upload fix
    watch = {"bowery ballroom": NOW}
    assert tag(TK, em(iso=OLD), ..., VENUES, EMAILS, set(), watch) is None

def test_not_started_venue_collects_nothing():
    assert tag(TK, em(), ..., VENUES, EMAILS, {"bowery ballroom"}, {}) is None

The second test is the regression guard for the ghost-venue bug. Without the watch_since cutoff it returns "Bowery Ballroom" and the old thread attaches to the new venue.

Two sources of time (shipped fix)

Threads sorted wrong, and an 8:11 PM send rendered as "Aug 4, 12:11 AM." Two row types carried two timestamp formats, Postgres ISO from the sent-store and RFC 2822 headers from Gmail, and each browser parsed them differently. The bug wasn't in either format. It was in having two.

The fix: _iso_norm converts any timestamp to UTC ISO at the API boundary. One clock, one format, so every browser sorts and localizes identically instead of guessing.

One funnel for notes (shipped)

Venue Notes was written by 10+ scattered sites, each inventing its own row shape: bracket markers, watch timestamps stored as content, ad-hoc summaries, all read back by regex guesswork. It's the same disease as the status column, which is many writers and no schema.

The funnel: one record_note path, a closed category set where a typo'd category is an error rather than a new kind, and render_row/parse_meta owning the wire format. It emits the exact bracket convention legacy readers already regex for, so the format gained a schema without a migration and nothing downstream broke.

Cost tie-inDerive-on-read can't drift, but it recomputes every turn. Materialized projections buy that compute back without giving up correctness: cached under a version key, refreshed only on write-driven invalidation. Correctness first, then cost, in that order.

Why it's a platform: the engine is vertical-agnostic. A health vertical like Meridian keeps the three layers, the funnel and the invariants unchanged. It raises the gate policy, swaps Sheets for Postgres, and the event log doubles as the compliance audit trail for free. Same engine, harder rules.

Depth · 05 When the model breaks

The model is wrong sometimes. That's a design input, not a bug report.

Reading a venue's email is the one place TM GO must use a model, because free-form human text has no parser. So the useful question was never "is the model accurate?" but which specific way does it fail, and what catches that one.

59
Unit tests, seven suites
32
Adversarial email-chain cases
10
Carry negative controls
100%
On both providers

Every defense sits in one of three places, and the place matters more than the cleverness:

Before the model

Machine mail never reaches the LLM. Bounces and auto-replies are matched in code (mailer-daemon, 550 5.1.1, "automatic reply") and short-circuited.

removes the failure surface
At the model

Eight numbered rules, each earned from a golden case or a promoted live miss. The prompt is a pure function, so the harness grades the exact production text.

shapes the output
After the model

Nothing reaches a band's sheet without review, and a problem verdict cannot write at all. The model may raise an alarm. Only a human acts on one.

bounds the damage
It invents an answerscorer

Nobody mentioned parking; a plausible parking answer appears. It’s the worst mode, because a confident wrong fact reads exactly like a right one.

Caught by negative controls: 10 cases ship forbid fields that must come back empty, so hallucinations cost score like misses and abstaining beats guessing.

It answers for the wrong showgolden case

One reply, two dates: "Oct 16 at Bowery, doors 8; your Nov 2 at Music Hall, doors 7." A naive extractor takes the last number it saw.

Caught by the multi-show bleed case, which fails if any other venue's numbers come back. Staff genuinely batch their replies.

It isn't a human at allcode, pre-LLM

An out-of-office is read as content, and "back on the 14th" becomes a load-in time.

Caught by _deterministic_reply_kind(), which deletes the failure mode rather than prompting against it. Measured: every model tier fails this case, and code is what fixes it.

It misreads severitystructural gate

"We have to move your date" gets mined for fields and updates the sheet, while the show is in jeopardy never surfaces.

Caught by a five-way classification where problem is barred from producing updates. Cancellations route to a human by construction.

It hears "no" as silencegolden case

"No catering, sorry" is dropped as a non-answer, so the system keeps asking and the band looks like it isn't listening.

Caught by rule 4, where an explicit NO is an answer, tested with the nastiest phrasing I could write: "I wish we had catering."

The provider degradesscheduled eval

Quality drifts under a model update, or failover behaves differently than the primary, and no deploy tells you.

Caught by scoring both providers (100% each), rerun weekly. Why a pinned name isn't a pinned model: see Evaluation.

The pipe breaks, not the model8 watcher tests

The least glamorous and most common. IMAP goes quiet, the server sends BYE, or a handler throws and kills the watch loop. Replies stop, and nothing looks broken.

Caught by tests on the distinctions that bite. A quiet timeout is not mail, a refused IDLE raises unsupported, backoff doubles and caps, and a handler error never kills the watch.

Right answer, wrong venue8 tagging tests

A perfect extraction attached to the wrong show. It's the state-engine bug class, arriving through the model's door.

Caught by explicit precedence: a binding beats everything, watch_since blocks mail predating a re-uploaded venue, fuzzy matching is opt-in.

The patternRead those eight down and one shape repeats: the model proposes; code disposes. None of it makes the model more accurate. It makes the model's mistakes cheap, which is the only property that survives contact with production.
Still thin: 32 cases scored once measures capability rather than consistency, and an agent can pass once and still be flaky. The fix is multi-trial pass^k with paraphrase perturbations, which I've built on another system but not here yet. Generation also has 2 judge-scored cases against extraction's 32, which is the weakest part of the harness and the next thing I'd grow.
Depth · 06 How the prompt learned

The prompt is an artifact of the test suite, not of taste

The usual way a prompt gets written is somebody tries wording until the demo looks good, and nobody can say later why any particular sentence is in there. This one has a paper trail. Every rule exists because a case failed without it, and the optimizer that proposes changes is scored by the same harness that gates the build.

The loop runs in four steps:

① ScoreRun the current production prompt over the golden set with repeat runs, and record which cases fail and which are unstable across runs.
② ProposeHand the failures, the expected answers and the current prompt to an optimizer model. It's told to make the smallest edit that fixes the failure, keep the output contract identical, and never instruct the model to guess.
③ CompareScore every candidate on the same harness. A candidate is promoted only on a real gain with no new instability, so a prompt that wins a point and introduces a flaky case is rejected.
④ ShipA human reads the diff before it goes live. The optimizer finds the fix, a person decides whether it's the right fix.
97.6%
Baseline mean score
100%
After one iteration
3
Candidates tried
1
Rule actually changed

The one failing case was Lincoln Hall, where the venue asked a question back that happened to contain a plausible answer. The extractor treated the suggestion as a confirmation. Here is the entire fix the optimizer proposed, and it is one sentence added to rule 6:

the promoted diff+2.4 points
  5. A deferred or conditional answer IS an answer...
- 6. A question BACK to us is NOT an answer ('how many in your party?'
-    answers nothing). Pleasantries and signatures answer nothing.
+ 6. A question BACK to us is NOT an answer ('how many in your party?'
+    answers nothing). Even if the question suggests a potential answer
+    ('Would 3pm work for load-in?'), it provides no confirmation.
+    Pleasantries and signatures answer nothing.
  7. Map colloquial times to the right field...

"Would 3pm work for load-in?" is a proposal, not a fact. Writing 3pm into a band's tour sheet because a venue floated it is exactly the class of error this system exists to prevent.

Why this matters more than the two pointsIt makes the prompt reviewable. Any rule in it can be traced back to a specific failing case, so removing one is a decision with a known consequence rather than a guess. That is the difference between a prompt you maintain and a prompt you're afraid to touch.

The full record, generated from the run history:

Open the full record →

Scope, stated plainly: this is one promoted iteration on a 42-case set, not a long optimization campaign. It also tunes against the same set that grades it, which is the standard trap: a real campaign needs a held-out split so a candidate has to prove itself on cases it never saw. The loop and the audit trail are the durable part. The 2.4 points are just the first thing it found.
Forward · 02 Scaling path

The scaling path, no longer just a plan

This section used to say "presented as a roadmap, not a current claim." Most of it has since shipped, behind flags, fail-open, with the migration gated by measurement. The statuses below are per target and honestly marked.

Breaks first at 100×
  • Sheets as database, with quotas and no transactions
  • IMAP polling and cold starts
  • Synchronous LLM calls on the request path
  • The three-source inbox merge
Target architecture, with status
  • Postgres system-of-record Shipped for venues and email state: shadow dual-write, backfill, a parity gate, and reads cut over on TMGO_STORE=db, with Sheets demoting to an export. Remaining domains still to migrate.
  • Queue + worker pool Shipped as a Postgres-backed jobs table. Workers claim rows with an atomic conditional update, so a racing pool has exactly one winner, and there's a test for the race. Exponential backoff, dead-letter rows that are never silently dropped. It runs in-process today via TMGO_WORKERS and promotes to a dedicated worker dyno with zero code change.
  • LLM gateway Shipped on the one traced_llm seam: deterministic-call caching, per-tenant daily budgets, and a usage endpoint. Failover was already there.
  • Event-driven email Partial. The Gmail push receiver is live and feeds the worker queue, acking fast and processing async. Pub/sub and watch enablement remain.
  • Multi-tenant isolation Row-level security is enabled on all five Postgres tables, and the eval gate runs in CI plus a weekly scheduled pass.
The migration patternStrangler fig, gated by measurement: shadow dual-write, backfill, a parity endpoint that proves the stores agree, flip the read flag, then demote the old store to an export. Every step is reversible, and the whole thing is flag-gated and fail-open, so deploying it changes nothing until a switch flips. Cutover is earned by a parity number rather than asserted.
Carry forward: deterministic-first design, provider failover, human-in-the-loop review, and the observability seam. The gateway and the queue were both built on existing seams, which is the proof those seams were real.
Still to do: finish the store migration's remaining domains, enable pub/sub for push, and promote the worker pool out of process under real multi-tenant load. Named so the shipped column stays honest.
Forward · 03 Summary

Summary

TM Go is a tour-management agent for independent bands. It advances shows over email and SMS, drafts venue outreach, parses free-form replies into structured logistics, and keeps each tour's records in sync. The central engineering decision was constraining the agent: a hand-authored LangGraph StateGraph replaces an open ReAct loop, with the model confined to drafting and parsing while all state, routing and I/O run as deterministic code. It ships with production-reliability plumbing, including provider failover, tolerant parsing, validated inputs and human-in-the-loop review, plus a traced_llm observability layer that doubles as a closed-loop evaluation harness. Every human correction becomes labeled data, and a CI gate blocks any change that reduces extraction accuracy.

One-line version"I constrained an agent so it could be trusted with real data, then instrumented it so that every human correction became evaluation data gating deployment."

🤖 Generated with AI, this one. I'll own it. The day one of these is my own typing, you won't need the disclaimer to tell. Fewer em-dashes, more typos. Trust me, you'll know.