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.
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.
System overview
The real deployed system, with prototype shortcuts named as such rather than hidden.
init_chat_model, OpenAI primary with Anthropic failoverTMGO_STORE=dbtraced_llm observability, closed-loop evaluation, and an /api/ai-health dashboardA bounded state-machine agent
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.
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.
# 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.
Three state machines, kept orthogonal on purpose
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.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.
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.
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.
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.
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.
The inbox as a workflow queue
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.
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.
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:
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.
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.problem verdict can't write updates.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.
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.
final_value is the right answer, already typed by someone who knew it.promote_evals.py turns those into properly-formed cases in candidates.json.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.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.
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.
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:
Migrating off the spreadsheet without a big-bang cutover
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.
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.
/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.TMGO_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.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.
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.
tours table with an owner and a member list, so a manager and a TM share one tour and nobody else can. 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.
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.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.
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.
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.
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.
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.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.
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.
Supabase ships pgvector. One extension, one column, one SQL function. There was no new system to run.
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.
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.
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."
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.
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.
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.
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
text-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.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.
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.
classify_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.process_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.draft_reply composes the outbound message; rundown_brief summarises a show. Both produce prose a human sends, never a value a system stores.decide · advance · send · cancel · answer · inbox · remember · redirect · chat. The model picks the lane and writes the answer; the lanes themselves are code.text-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.judge_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.
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.
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.
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.
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.
TMGO_STORE=db, and an export endpoint starts the demotion to a report. Left: the remaining domains, then contracts.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.pass^k, paraphrase perturbations, per-case history.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:
The levers, in the order I'd pull them:
traced_llm records tokens and cost per call, per provider. Every lever below is verified against that meter rather than assumed.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
Cost
Latency
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.
# 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
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.
In production that email never reaches a model. _deterministic_reply_kind() matches the auto-reply signature and short-circuits first.
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.
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:
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 →
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.
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.
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.
# 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.
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.
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.
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.
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.
Every defense sits in one of three places, and the place matters more than the cleverness:
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.
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.
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.
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.
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.
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.
"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.
"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."
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 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.
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.
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.
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:
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:
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.
The full record, generated from the run history:
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.
- 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
- 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_WORKERSand promotes to a dedicated worker dyno with zero code change. - LLM gateway Shipped on the one
traced_llmseam: 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.
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.
🤖 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.