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. So it carries the most tests, 59 across seven suites, and most of them are about telling apart states that look alike.
IMAP IDLE is the clearest example. The protocol is full of lookalikes, so each distinction gets pinned:
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 32 email-chain cases, 10 of them with negative controls, so invented answers cost score and abstaining beats guessing. It reruns weekly on a schedule, because model drift doesn't wait for a commit.
One tracing seam
traced_llm is the single point every model call passes through. It records endpoint, model, latency, tokens, estimated cost, and whether failover fired.
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.
Two LLM seams, and everything else is code
The model appears at exactly two points, both of them language problems: parsing a venue's free-form reply into typed fields, and drafting the outbound message. Routing, scheduling, sheet writes, validation and retries are all deterministic Python. That placement is the load-bearing decision the other six hang off.
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 touches the model exactly twice, to parse the reply and draft the response. So cost scales with shows advanced rather than chat length or agent wandering. That's the bounded graph paying out: no re-planning loops means no unbounded spend. The levers, in the order I'd pull them:
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 same 32 golden chains with the production prompt lifted straight out of the running code, across three tiers, recording accuracy, cost and latency in one pass.
Accuracy
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 with paraphrase perturbations, which I've built on another system but not here yet. Generation also has 2 judge-scored cases against extraction's 32, which is the weakest part of the harness and the next thing I'd grow.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 Row-level security is enabled on all five Postgres tables, and the eval gate runs in CI plus a weekly scheduled pass.
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.
🤖 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.