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
61
Adversarial email-chain cases, 27 with negative controls
2
LLM providers with automatic failover
64
API routes, every one behind the tenancy guard
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. 64 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
DataSupabase Postgres for auth, storage, tenancy and AI telemetry. New tours are DB-native; onboarding no longer asks anyone to link a spreadsheet. Google Sheets survives only for tours already built on one, and is demoting to an export — venues, email state and the contract path have cut over behind a parity gate on TMGO_STORE=db
AccessEvery tour has an owner and a member list. All 64 routes are wrapped at registration by one guard, and a test walks the real route table so an unwrapped endpoint fails the build. See Decision 08
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. The repo runs 378 tests across 18 suites, and the conversational and document path holds the largest share — 77 on the chat brain alone, plus grounding, the watch loop, inbox tagging and the gateway. Most 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 (61 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. 27 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 61 email-chain cases, 27 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%
Last full pass, both providers
378
Unit tests, 18 suites
61 / 27
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.
Decision 08 Tenancy

The access boundary, and why signing in was never one

Every table in the app is keyed by a tour id, and until this week nothing anywhere recorded who owned it. Signing in proved who you were. It never proved the tour was yours.

Request Carries a tour id In the query string, the POST body, or a whole spreadsheet URL. All three had to be covered.
Gate 1 Who are you? Authentication, shipped earlier. Necessary, and on its own it changed nothing here — any signed-in user could still read any tour by passing its id.
Gate 2 Is this yours? Ownership, and the gap. A tours table with an owner and a member list, so a manager and a TM share one tour and nobody else can.
Handler 64 of them The boundary has to hold for every one — including the one added next week.

Only the first gate existed — and for sheet-backed tours the id is the spreadsheet id, which travels in a URL. It was never a secret to begin with.

This was the last structural thing that had to land before real users: after the first paying customer, every migration here is a migration of somebody's live data. Three decisions in it matter more than the schema.

Claim on first useNo backfill, no downtime, no migration window. A tour with no owner row is unclaimed, and the first authenticated caller to touch it becomes the owner — a unique index on tour_key makes that race safe. Existing tours keep working and acquire an owner the moment their owner next opens the app. Same strangler shape as the store cutover: expand, adopt, contract.
Wrapped at registrationNot a helper each handler calls. A check you have to remember will be missed on the next endpoint, and this codebase adds endpoints weekly — so the boundary is applied in one place, to all 64 routes, by construction rather than by discipline.
A wrapper, not middlewareThe guard reads the POST body to find the tour key, and request.json() caches on the Request instance. A wrapper hands that same instance down, so the handler's own read hits the cache. BaseHTTPMiddleware builds a fresh Request and the cache goes with it — every POST handler would have seen an empty body. There's a test for exactly that.

The invariant: you can't add a route that skips the boundary

Wrapping at registration only holds if something fails when one slips through. So the suite walks the real route list rather than trusting the convention.

test_tour_access.py21 tests · this is the load-bearing one
def test_every_registered_route_is_wrapped():
    """A check you must remember to call WILL be missed on the next endpoint."""
    unwrapped = []
    for node in ast.walk(tree):              # the real Route(...) list
        if not _is_api_route(node):
            continue
        handler = node.args[1]
        if not _wrapped_in(handler, "guard_tour"):
            unwrapped.append(node.args[0].value)
    assert not unwrapped, (
        "these routes bypass the tour boundary — wrap them: " + ", ".join(unwrapped))

The other 20 are the bypass attempts and the things that must not break: the key hidden in a POST body, the full spreadsheet URL, the tour_key alias, an anonymous caller, a second member who should get through, a claimed tour locking everyone else out afterwards, and the body surviving the check.

The gate I debugged for an hour

Seven tables returned 42501 and I went looking at the key. RLS and GRANT are different gates with opposite signatures: RLS decides which rows a role sees and denies by returning zero rows; GRANT decides whether it may touch the table at all and denies with 42501.

Every schema file enabled row-level security and not one granted the table, so a perfectly valid key was refused everywhere. Health-check now branches on the key's own role claim and names the gate that's actually shut.

So what actually isolates tenants

The guard, in code. The API holds the service-role key, which bypasses RLS by design — so RLS is not what keeps tour A out of tour B, and saying it was would be the exact false assurance this whole page argues against.

RLS-with-no-policies is the second gate: it means a browser key reads nothing directly. Two mechanisms, two jobs, named separately.

PrincipleA boundary you have to remember is a convention. A boundary that fails the build when it's missing is an invariant. The difference is the whole reason this is applied at registration and tested against the real route table rather than documented in a README.
Honest scope: enforcement is tied to AUTH_ENFORCE, because with auth off there is no user to check against and turning this on alone would lock people out without securing anything. Claim-on-first-use also means the first caller wins — correct for a single-operator tour, and it would need an invite flow before a tour could legitimately have several owners from day one.
Decision 09 Document retrieval

I argued against vector search twice. The numbers settled it.

A tour manager uploads a rider, a contract, a hotel confirmation and a stage plot, then asks "what time do we arrive?" — a question whose answer could be in any of them. This is the decision I got wrong longest, and the record of changing my mind is more useful than the design I landed on.

① Keyword windowsScore paragraphs against the question's words, keep the head of the document for parties and dates. Deterministic, free, debuggable. Fails on paraphrase: "what gear does the house provide?" shares no word with "BACKLINE: Vox AC30 supplied by venue."
② A synonym mapHand-written domain vocabulary — backline means amps, hospitality means catering. Cheaper than embeddings and correctable, which a general embedding model is not. It worked, and it was the first sign I was building the wrong thing.
③ Whole documentsThe real bug turned out to be a number, not a strategy: documents are stored capped at 20k characters, the model's window is 128k, and my excerpt budget was 6k — so 70% of a rider that would have fitted comfortably was discarded before the model saw it. When everything fits, selecting can only lose the answer.
④ Don't filter by kindRetrieval fetched the document whose type matched the question. Arrival time lives in the hotel confirmation, not the contract the question sounds like — so filtering by kind misses by construction. All of a venue's documents go in; the kind cue only orders them.
⑤ pgvectorChunk, embed, and search by meaning. What I had resisted through the four steps above.

Why I was wrong, argument by argument

The tell wasn't a benchmark. It was what I had accumulated instead: keyword scoring, a synonym dictionary, a relevance gate, a budget packer and kind-ordering — five pieces of homemade machinery approximating what Postgres does natively, and two of my own tests fighting each other over it. So I checked the arguments honestly rather than defending them.

"It's new infrastructure"false

Supabase ships pgvector. One extension, one column, one SQL function. There was no new system to run.

"A new place for tenant bugs"overstated

Same table, same sheet_id filter — and now inside the SQL function, where a caller can't forget it. That omission is the actual way vector stores leak.

"Everything fits in context"true, then false

True at one to three documents. False for the stated use case — dump every document for a show and ask across it. Fifteen documents is ~75k tokens per question to surface one paragraph.

Costthe one that flipped it

Stuffing 8 documents: ~$0.0060 a question. Retrieving 6 chunks: ~$0.0003. 20× — and the gap widens with every upload, which is the wrong direction for a feature whose whole promise is "put everything in here."

The lesson worth keepingI wasn't reasoning from the problem, I was defending a position. The signal was five hand-built pieces approximating one library, and I should have read it as an argument against myself several commits earlier than I did.

Then I audited what I had just shipped, and found three bugs

Reviewing the new design as a design rather than defending it, in the same session it landed. All three were live.

Duplicates accumulated foreverdedupe never fired

Chunks were keyed on document_id, but the store returns a bool and never an id — so the key was always NULL, and Postgres treats NULLs as DISTINCT in a unique index. Upload indexed 20 chunks; approve indexed 20 more.

Fixed by keying on content_hash — derivable, stable across re-uploads, never null.

Top-k always returned kthe hallucination path

With no similarity floor, a question the documents don't answer comes back with the k least-unrelated paragraphs. The model is handed noise and invited to build an answer on it — a hallucination wearing a citation, which is worse than no answer.

Fixed by a cosine floor of 0.25 in the SQL function, so "nothing matched" is expressible.

Cross-venue questions never reached the indexscoping bug

Retrieval only ran once a venue had been named, so "which venues have a radius clause?" — the exact question a vector index is best at, and impossible to answer any other way — skipped documents entirely.

Fixed by searching the whole tour when no venue is named. It was also the case I'd argued was the only reason to add vectors, and I'd shipped it broken.

The shape it settled into

Chunking1,000 characters on paragraph boundaries, so a clause keeps the heading that says what it is, with 150 characters of overlap so a sentence split at a boundary survives whole somewhere.
Indextext-embedding-3-small with an HNSW index — no training step, so it stays accurate as uploads trickle in one at a time rather than needing a rebuild.
Indexed twice, deliberatelyAt upload and again at approve. Chunks carry the venue and search filters on it, so a corrected venue that reaches the document but not its chunks leaves the file findable and its contents not.
Query rewriting"What about parking?" is four words, two of them stopwords; its nearest neighbours are whatever the index thinks four stopwords mean. Short follow-ups now carry the venue and the previous question. Concatenation, not a model call — no latency, no cost, nothing new that can hallucinate. Spend a call on it only when concatenation demonstrably isn't enough.
FallbackNo key, nothing indexed yet, or a failed search falls back to whole-document stuffing. Degraded, not broken. The synonym map survives only there, and stops growing.

Why Postgres rather than a vector database

A tour is roughly 2,400 chunks; a thousand customers about 2.4M — which HNSW handles on ordinary Postgres. But scale isn't the reason. Tenancy lives in Postgres. A second store means the same tenant rule enforced in two systems, a venue delete that has to succeed in both, and "delete my data" becoming a two-phase problem.

The specific tell: both common framework wrappers put the tenant filter in a caller-supplied metadata dict — somewhere it can be forgotten. Ours is in the SQL function signature, where it cannot be.

Still honest about this: the similarity floor is one number chosen by hand, not tuned against a labelled set — I know it stops noise reaching the model, and I can't yet tell you what it costs in recall. That needs a retrieval eval with graded relevance, which is the next thing this deserves and doesn't have.
Depth · 01 Where the intelligence lives

Seven model seams, none of them able to write

This section used to say "exactly two." It hasn't been two since document intake and chat landed, and the honest count is more convincing than the tidy one — because the rule it was protecting never changed. The model appears wherever there is language to read or write, and nowhere near a fact.

Read a documentclassify_upload decides what a file is; extract_by_kind and parse_contract read fields out of it once the kind is known. All three propose — the analyst confirms before anything lands.
Read a replyprocess_reply turns a venue's free-form email into typed logistics. Still the seam with the most tests behind it, because it's the one whose mistakes reach a band's records.
Write languagedraft_reply composes the outbound message; rundown_brief summarises a show. Both produce prose a human sends, never a value a system stores.
Answer a questionThe chat graph — nine nodes: decide · advance · send · cancel · answer · inbox · remember · redirect · chat. The model picks the lane and writes the answer; the lanes themselves are code.
Find the passagetext-embedding-3-small over document chunks. Retrieval isn't generation, but it's a model in the path and it belongs on this list — its failure mode is handing the answer-writer the wrong paragraph. See Decision 09.
Grade the outputjudge_email — a model scoring generation quality in the eval harness. Off the production path entirely, which is why it can be a model at all.

Routing between lanes, scheduling, every write, validation, retries and the entire state engine remain deterministic Python. That placement is the load-bearing decision the other eight hang off, and it survived the seam count more than tripling — which is the actual test of whether a rule was structural or just a small system's convenience.

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 nine decisions are one bet made nine 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 still touches the model exactly twice — parse the reply, draft the response — so that surface scales with shows advanced rather than agent wandering. That's the bounded graph paying out: no re-planning loops means no unbounded spend. But it is no longer the only surface, and saying so is the point of metering:

AdvancingTwo calls per cycle, bounded by the graph. Predictable, and the cheapest thing here.
Document intakeOne classification plus one extraction per uploaded file — a one-off per document, not per question, which is what makes it affordable to index rather than re-read.
Chat and document Q&AThe surface that actually grows. It's why Decision 09 matters as a cost decision and not only a quality one: stuffing eight documents ran ~$0.0060 a question against ~$0.0003 retrieving six chunks — and the stuffing figure climbs with every upload while the retrieval figure doesn't.
EmbeddingPaid once at upload and again at approve, never per question. The re-index at approve is a correctness cost, not a wasted one: chunks carry the venue and search filters on it.

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 golden chains with the production prompt lifted straight out of the running code, across three tiers, recording accuracy, cost and latency in one pass. Each bench below is a dated snapshot — this one ran at 32 chains and the OpenAI run further down at 42, because the set has kept growing. What matters is that within a run, the only variable is the model.

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.

378
Unit tests, 18 suites
61
Adversarial email-chain cases
27
Carry negative controls
100%
Last full pass, 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.
Consistency, not just capability: a case scored once measures luck — an agent can pass on one run and still be flaky. So the harness reports pass^k, the share of cases that passed every run, alongside the mean. That's what caught the rule 11–13 ablation being worth 2 points of stability and only 0.4 of accuracy, and it's the number I'd trust over a single green pass.

Still thin: paraphrase perturbations cover 8 venues × 3 rewordings, not the whole set, so a case I only ever phrased one way is still measuring my prose. Generation has 2 judge-scored cases against extraction's 61 — 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 Shipped. Tours have an owner and a member list, and all 64 routes are wrapped at registration by one guard, with a test that walks the real route table so an unwrapped endpoint fails the build. That guard is what isolates tenants — the API holds the service-role key, which bypasses RLS by design. RLS-with-no-policies is the second gate, and it's what stops a browser key reading anything directly. Decision 08 has the detail.
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. Tenant isolation is structural rather than remembered: every tour has an owner, all 64 routes are wrapped by one guard at registration, and a test walks the real route table so an unwrapped endpoint fails the build. Documents a band uploads — riders, contracts, hotel confirmations — are chunked and embedded into pgvector alongside the tenancy they belong to, so a question can be asked across every document for a show at once, and the tenant filter lives in the SQL function signature where it cannot be omitted.

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.