How The Crate's concert pipeline was built
Peter Morrissey ·
Unlike Advisory, these dev explainer articles may have some text written by AI. I'm okay with that since these are largely just summarizing a history of completed and existing work. I always edit by hand and ensure everything is accurate.
The essay · redfinchadvisory.com How AI’s Failures Unlock Your Hardest WorkflowsThe method this build follows, step by step, without the code.- The Crate app
- Ingest pipeline
Week of 26 Oct 2026 · 340 commits · The Crate app 10 · Ingest pipeline 330
This is the longer and more technical version of the Crate example in How AI’s Failures Unlock Your Hardest Workflows. It follows the same steps in the same order: a goal, context and guidance, a target, the agent’s first try, the training, the paving and the gates. Each step shows what happened in the code, with the numbers and the tools.
Two pipelines carry the story. concert-db is the first one. An agent loop built it in June 2026, and it still feeds the concert listings in The Crate for 31 metro areas. crate-ingest is the second. I started it on 23 October 2026 to replace the first, and it is switched on for one city, Pittsburgh. Nothing it makes reaches the app yet. The pipeline’s own page says how it works today. This piece says how it got that way.
- Commits in crate-ingest
- 570 in 14 days
- With a Claude co-author line
- 479
- Recorded decisions
- 287
- Experiment plans and readouts
- 117 files
- Tests
- 1,063
The first trail: concert-db
The goal I gave the first loop was as broad as the one in Desire Paths. In its progress file, my instruction reads: “stop when you’re sure you’ve captured EVERY concert.”
The context was small. A research file listed every Pittsburgh venue I knew of. SeatGeek’s API gave a backbone of ticketed shows. The agent searched the web through Chrome. Claude Code ran the whole thing on a /loop every 15 minutes from 16 June 2026. Each round it sent Sonnet subagents out to sweep a group of venues (the big halls, the suburban rooms and casinos, breweries and hotels, museums and churches) into raw files. Then it checked each file, merged it and rebuilt the database.
It worked better than I expected. By the second day the database held 790 shows at 96 venues, up from 345 after the first sweep, and the app was showing them. Over the summer it grew to 31 metro areas with a refresh every morning.
I was thrilled to find venues near my house that I didn't know existed, and clean calendars for shows in my area.
It also wandered, had lots of duplicates, thought concerts from 4 years ago were happening this year, and generally had a pile of misinformation.
When I reviewed it on 2 October 2026:
- About a fifth of the upcoming shows came from one snapshot file that nothing refreshed.
- 1.6% of upcoming show ids changed in a single week. Each change broke a user’s saved RSVP.
- Venue ids were made from names, so one id stood for six different theatres called “Palace”.
- One research lane put this year’s date on a 2016 article, and a festival that was long over went out as a live show.
The agent had found a lot of real shows and had some really good data. However, it could not tell me why it believed any one of them, so I had nothing to turn into a system that I could pave, and scale reliably.
Sure, a lot of these issues can be traced to very poor engineering and thoughtless design that I brute forced through. In hindsight, I’m kind of thankful for what I learned by being thoughtless. It gave me a better sense of where steering was truly required, and what these systems could figure out for themselves.
Writing the path down before the code
crate-ingest started with documents, not code. Its first commit is called “seed spec, principles, lessons, decisions round 1”. The goal got rewritten too:
The most up-to-date and accurate database of shows, venues and the artists playing them, everywhere. A city is a method for getting there, never the goal.
The context and guidance from Desire Paths became files that every Claude Code session reads before it starts:
CLAUDE.md, the project’s standing instructions.spec/principles.md, twelve principles. Two of them carry most of the weight: “Every fetch is saved as an immutable capture,” and “Rules first, models second. Every model call goes through one gateway.”spec/rules/, numbered rules for sources, the model gateway, evals and tests.spec/lessons.md, what concert-db got wrong, written as numbered lessons (the first one is the 2016 article).spec/decisions.md, every choice I made, with my answer in my own words next to it.
Most of those decisions were made on decision boards: a page of numbered questions with lettered answers and one marked as the recommendation. I click a letter and add a comment, and the next session reads my picks from the page. The log holds 287 of them, and many quote my answer as I typed it (“Lets go with haiku for hte recipes.”).
The other habit was making the system visible from its own code. A generated viewer shows every table, field and route. A stage map lists each step of the pipeline. Two tests fail when the viewer or the stage map no longer match the code, so the documents cannot drift away from what the pipeline does.
The playbook (docs/playbook/agentic-pipelines.md) is where the lessons go. It has 60 numbered sections, each with the experiment or mistake behind it. Its first one is “Write the rules down before the code.”
Desire Paths says a known good output makes the cycle work early. Here the target is concert-db itself. It has flaws, but it has years of my firsthand checks of Pittsburgh behind it, and it is the best list I have.
The score is coverage: the share of concert-db’s upcoming Pittsburgh shows that crate-ingest finds on its own, starting from an empty database. Nothing from the old feed goes in, and I don’t step in to do anything manual. The bar is in playbook section 33: one run from empty to full coverage, and “follow-ups by hand are the system failing.”
| Date | Coverage | What changed |
|---|---|---|
| 31 Oct 2026 | 37.8% | First full pass |
| 31 Oct 2026 | 50.0% | After the first lap of the loop |
| 2 Nov 2026 | 66.1% | On a copy of a later test run |
| Aim | 85 to 90% | The fixes in cold run 2, which is still running |
The other target is my own answers. One of the principles says “Founder verdicts are the labels.” Every time I settle a question by hand, the answer can become a test case. The labelled sets so far: 350 venue pairs, 161 duplicate-show pairs, 152 event kinds, 139 source pages, 249 artist pairs. Evals run in Braintrust against frozen captures.
Two limits belong here. First, every label set is from Pittsburgh. Second, the first baseline was partly circular, because its truth files for two sources were built from the same structured data the rules read.
For something I am building from scratch, I needed to be involved in creating the target as we go, and be active as the source of truth.
Execute, and keep everything
In Desire Paths I talk about how the agent is going to fail, and that is the plan. The engineering question is how cheap each failure is to study. In crate-ingest, four things make it cheap.
Every fetch is kept. Each page read is saved as a capture in Cloud Storage, addressed by its own hash and never overwritten. The captures are the test fixtures. A parser or prompt change is proved by replaying it on them before it ships.
Every model call goes through one gateway. Pipeline code asks for a task, such as extract.events, never for a model. Which model does the task lives in one file:
config/models.yaml, two of the tasks
extract.events: {provider: anthropic, model: claude-haiku-5-5, max_tokens: 16000, effort: medium, # D-091, experiment 11: 120/120 for $0.11 (Sonnet alone $0.81)
cascade: {provider: anthropic, model: claude-sonnet-5-5}} # re-reads only a page part whose dates the free date check doubts
judge.dupe: {provider: jev, model: jev-1.14.0, max_tokens: 1000, effort: low, # D-068, experiment 9: 146/150, 0 wrong merges
cascade: {merge_at: 0.90, apart_below: 0.30, provider: anthropic, model: claude-sonnet-5-5, then_merge_at: 0.70}}
Changing a task’s model is a reviewed change plus an eval run. The same file holds the caps: $150 a month, $10 a day, and a cap for each task.
Every call is approved and recorded. The gateway has four modes: replay, approve, record and live. Replay is the default, and a call with no recording is an error. At first I approved each call by typing y, n or q. That broke when I approved over Remote Control, which lost most of my typed answers. Now a whole run gets one approval file with a call count and a hard cap for each model line. An approved answer is recorded, so the same question is never paid for twice. There are 1,328 recordings pinned in the tests and 3,736 in the evals.
Every run writes its own story. A run row per invocation and a run_event per step, decision, skip and failure. crate-ingest runs story <id> prints the history of one venue or show, and runs export makes a readout page for the whole run.
With those four in place, the misses are easy to find. A few of the real ones:
- A trial let a Memphis theatre, a Harrisburg church and 14 single Eventbrite event pages in as Pittsburgh sources.
- Haiku put 7 of 120 dates a day early.
- A model answered “different, confidence 0.1” about two cafes. The code read that as a 90% chance they were the same place and merged them.
- On the first cold run, 87 of 275 venue merges carried a reason that itself said they were two businesses or two rooms.
- One long run spent 19 hours on artist steps with the MusicBrainz copy switched off, merged nothing, and reported every step as ok.
A short note on access, since people ask how the pages are fetched. The pipeline reads public pages only, with an honest user agent that names the project and gives a contact address, at about one request a second per site. A login page, cookie wall, CAPTCHA or challenge is a stop, and not something I route around with an agent.
Train: the two questions, in the code
The training step in Desire Paths asks two questions. How do I know this is wrong? How can I equip the agent with that? Each miss above went through those two questions and came out as a rule. Pick one to step through it.
Venue addresses
- Agent executes The venue judge decided whether two place records were one building. It merged some pairs on names alone, and 266 of 331 judge cards had one side with no street at all.
- How do I know it's wrong? I know the rooms. Two businesses with similar names are two places, and a card with no street gives nothing to compare.
- How can I equip it? Give every new place a street and a map point when it is made: Overture's place data first, then the US Census geocoder. Keep each street, point and alias as a fact with its source and date.
- Pave Names alone never merge. A pair with nothing but names gets a "need more" verdict, and a step gathers evidence (Overture, the venue's own site, one capped search) before the judge looks again.
- Gate A check lists places whose facts disagree: two streets, or two points more than about 300 m apart. Every merge keeps the verdict that made it and can be undone.
Dates
- Agent executes Haiku read five venue pages and found 113 of 120 upcoming shows with the right date. Sonnet found all 120.
- How do I know it's wrong? A listing page puts each date on the same side of its title every time. Haiku's 7 wrong dates each came from the listing above.
- How can I equip it? A free check with no model: find the page's side from its own events, then ask whether each event's date sits on that side of its title. It doubted all 7 wrong dates and none of the 233 right ones.
- Pave Haiku reads first and Sonnet re-reads only the parts the check doubts: 120 of 120 for $0.11, against $0.81 for Sonnet alone.
- Gate A new source goes live only if every upcoming date it gives passes the check on its first read.
Artists
- Agent executes The pipeline matched each billed name to an entry in MusicBrainz, the open music catalogue. The ticketing feeds' own MusicBrainz ids were wrong about 1 in 20 times, and they drove 7 of 8 wrong picks.
- How do I know it's wrong? I narrated how I check a match. I open the venue's own listing, then the catalogue entry's website, and see whether its shows page lists the same date, or whether both pages link the same Spotify artist.
- How can I equip it? Two of those steps need no model at all: the same link in both places, and the show on the band's own page. Read the pages rendered, the way I see them. On plain fetches the check found 5 matches, and on rendered pages 46.
- Pave The two steps became a rule. On 128 acts it settled 46 with no wrong answer (playbook section 16).
- Gate An act is confirmed only by a shared link or by its own page listing our show, never by a name or a feed's id alone.
Low confidence
- Agent executes Haiku answered one pair of cafes "different, confidence 0.1".
- How do I know it's wrong? They were merged. The code had turned "10% sure they are different" into "90% sure they are the same".
- How can I equip it? Read any answer under 0.5 confidence as unsure, whichever way it leans. All 14 such answers on those cards were "different" at 0.1 to 0.45.
- Pave "A low confidence is unsure, not its opposite." An unsure answer goes one step up the cascade.
- Gate A text model's answer under 0.5 never decides. Replayed on the recorded cards, the rule removed that wrong merge and changed nothing else.
Wrong city
- Agent executes A trial run admitted a Memphis theatre, a Harrisburg church and 14 single Eventbrite event pages as Pittsburgh sources.
- How do I know it's wrong? Their events are not in Pittsburgh. The trial checked dates and yield, not whose events they were.
- How can I equip it? Ask whose events they are: does the page name a town in the market, in its host, title, event data or text?
- Pave A lead with no known place whose page names no market town is turned down and rechecked in 90 days. A page for one Eventbrite event is turned down by its address alone.
- Gate Sites outside the market are turned down before any reader runs on them.
Silent steps
- Agent executes A long run spent 19 hours on artist steps while the MusicBrainz copy was switched off. It merged nothing, and every step said ok.
- How do I know it's wrong? The database did not change. Nothing a step claimed showed up in the rows.
- How can I equip it? A step's status comes from its effects: it is ok when it added a row, wrote an event, or says it writes none.
- Pave The funnel gate: any lead, source or claim sitting in a state that no step reads fails the run. On the runs before it, it would have caught 1,464 leads never looked at and 11,609 claims never resolved.
- Gate A pre-flight check before every long run: the keys, every model line the code can call, the MusicBrainz copy and the Census file.
The artists card is the one I would point a team at first. When a person checks an agent’s work, the clicks they make are a specification for the next version of the agent. Ask the reviewer to narrate one or two checks, list the actions in order, and mark each one: a script can do it, a model can do it, or only a person can.
Playbook section 45 adds a warning about moving a hand rule into the pipeline: move the rule, not the script, then rerun the pipeline version on the experiment’s own cases. A hand reader adapts without noticing. One band’s page wrote “6 Nov, Fri”, day before month, at an address called “/gigs”, and the pipeline rule missed it until the rerun showed the gap.
Pave: recipes and the reader ladder
Paving is where an agent’s judgment turns into code that runs the same way every time. The biggest piece of paving in crate-ingest is how it reads a venue’s page. It goes down a ladder and stops at the first rung that works:
- A feed iCal or RSS. rules
- A platform endpoint or the page's own data rules
- A ticket platform's page for the venue rules
- schema.org event data in the page rules
- The site's event pages rules
- A recipe only on a page that shows an upcoming dated row. model writes it once
- One bounded render in a real browser. Playwright
Most venue sites are built on a handful of platforms: Squarespace, Wix, WordPress event plugins and others. Each of those has a reader written as rules, with no model.
For a page with no structure, a model writes a recipe: a small file that says which part of the page is one show, and where its date, title and acts sit. From then on, rules read the site with that recipe on every visit for free. A check runs on every read, and a flag sends one repair call to the model. This one was written by hand by Claude Opus in experiment 17. In a run, Haiku writes one like it for each new text page:
config/site_recipes/clubcafelive.com.json, trimmed
{
"site": "clubcafelive.com",
"notes": "Squarespace list section. One li per show; date, times and price are free-text paragraphs.",
"row": "li.list-item",
"fields": {
"title": {"sel": ".list-item-content__title"},
"date": {"sel": ".list-item-content__description", "format": "auto"},
"start_time": {"sel": ".list-item-content__description", "pick": "show"},
"doors_time": {"sel": ".list-item-content__description", "pick": "doors"},
"status": {"sel": ".list-item-content__title", "re": "(sold out|cancel+ed|postponed)"}
},
"year_rule": "noyear-next-occurrence"
}
Recipes started on Sonnet, and by 30 October recipe writes were 68% of all model spend. Experiment 18 tried Haiku on the same pages. Haiku’s recipes found 97.4% of the rows against Sonnet’s 97.8%, at a fourteenth of the cost. I picked Haiku on a decision board.
The date check is the other kind of paving: plain code that does what a careful reader does. Its header says what it found:
src/crate_ingest/extract/date_check.py, the docstring
"""Experiment 11: a free check on a model's event dates, so a cheap reader can do the first pass and a strong
one re-reads only what looks wrong. No model call.
A listing page puts each event's date on one side of its title, the same side every time. The check
finds the page's side from the events themselves, then asks of each event: is the first date on that
side of its own title the date the model gave? If the page also carries a machine date there
(`{startDate: ...}`, `{datetime: ...}` from `model_html.clean_text`), it must agree to the day.
On experiment 7's recordings it doubts all 7 of Haiku's wrong [...] dates (each taken from the
listing above) and none of the 233 right Haiku and Sonnet dates. Of errors planted one at a time in the
right answers (a day off, the listing above's or below's date) it catches 99 to 100%. Its blind spot: a
weekly event moved by exactly a week lands on its own next listing (88% caught). `evals/date_check_eval.py`.
"""
The last piece of paving is the daily job. It runs on Cloud Run at 06:15 Eastern, reads every live source, resolves and publishes, and never calls a model. It runs in replay mode, so every model answer it uses was approved and recorded earlier.
Gates: what nothing passes without
The note at the end of Desire Paths says a hands-off step needs deterministic gates. These are the ones in crate-ingest, each with its rule.
- Names alone never merge. A venue joins another only on evidence: a host, an address, a map point.
- An act is confirmed by evidence: a shared link, or its own page listing our show.
- A source goes live on one read only if every upcoming date passes the date check, it yields upcoming shows, its events sit in the market and it is not stuck.
- A state with no reader fails the run (the funnel gate).
- A long run starts only after a pre-flight of its keys, model lines and files.
- Spend stops at the caps, counted in the gateway under one lock, including calls still in flight.
- A release is blocked if it fails schema validation, repeats an id, or is under half the size of the last one.
make checkruns before every commit and must stay under a minute: lint, the tests in replay mode, the replay check on every frozen capture, and the duplicate-show eval. A drop fails it.
src/crate_ingest/api/publish.py, the release gates
gates = {
"validates": {"ok": not invalid, "invalid": invalid[:20], "invalid_count": len(invalid)},
"unique_ids": {"ok": not dupes, "duplicates": dupes[:20]},
"count_drop": {
"ok": ratio_ok,
"count": count,
"previous": prev.event_count if prev else None,
"previous_release": prev.id if prev else None,
"min_ratio": MIN_RATIO,
},
"forced": force,
}
if not force and not all(g["ok"] for g in gates.values() if isinstance(g, dict)):
raise PublishGateError(gates)
One rule covers the tests themselves. A new test is written only when a bug escaped once, or when the code decides identity, dates or what is published. Each such test is run against the code before the fix, and it must fail there.
How much of me is left
Graduation in Desire Paths means a person steps in only when needed. Here is where that stands.
Yes-or-no questions go to Jev first. It is a small decision model from TypeSafe AI that answers typed questions with a probability and writes no text, at $0.042 per million input tokens. What Jev leaves unsure goes up a ladder of stronger readers, and only what the last one leaves unsure comes to me. For “are these two records one building?” the ladder looks like this:
config/models.yaml. Each rung merges at its upper line and keeps the pair apart below its lower line. Pairs in between go up a rung. After Sonnet, a pair between 0.30 and 0.70 comes to me.On address-filled cards, Jev’s form alone merged 88.0% of the pairs and sent 3.6% to me, with 4 wrong merges. Model spend for a city’s first run is in the tens of dollars at Haiku prices. A single call costs from about $0.00002 for Jev to $0.06 for a Sonnet recipe retry.
Some decisions stay with me on purpose: what the fields mean, merging code, keys and money, and which city comes next.
And the honest count: on 4 November I found that I review the pipeline’s work in 19 separate places, and 167 of my answers on two review pages were never applied. That is the subject of the next piece, on why a human in the loop is there to teach, not to check.
The toolkit
| What | Tool | Why |
|---|---|---|
| Building | Claude Code (Claude Opus 5.5 and Fable 5.1), one git worktree per session | 479 of 570 commits carry a Claude co-author line. Parallel sessions never share a checkout. |
| Decisions | Decision boards, spec/decisions.md |
Every choice has an id, my words and a reason, so a later session can find it. |
| Reading pages | Claude Haiku 5.5, Claude Sonnet 5.5 through the Anthropic SDK | Structured output into Pydantic models. A refusal or a cut-off answer is an error, never half a claim. |
| Yes-or-no judging | Jev 1.14.0 (TypeSafe AI), through the Vercel AI Gateway | A probability for a few thousandths of a cent a call. |
| Evals | Braintrust | Frozen captures plus my checked answers. |
| Browser | Playwright with Chromium | For pages that only draw their events with script, read once and within a time limit. |
| Places | Overture Maps (read with DuckDB), the US Census geocoder | Free place data and addresses before any paid search. |
| Artists | MusicBrainz, our own copy in Cloud SQL | Exact names in 82 ms, without the web API. |
| Search | Serper | Google results through an API, with a ledger of every search. |
| Data | Postgres 17 with PostGIS, SQLAlchemy, Alembic | 45 tables. One ontology in LinkML generates the models, the JSON Schema and the TypeScript types. |
| Running | Cloud Run jobs, Cloud Scheduler, Cloud Storage | One daily job in the cloud. Discovery runs by hand, as experiments. |
The repo holds 76,096 lines of Python, of which 17,134 are tests and 16,341 are evals.
What isn’t done
- Only Pittsburgh is switched on. Columbus has a first pass.
- Nothing crate-ingest makes reaches the app yet. concert-db still feeds all 31 metro areas.
- Every coverage figure here is measured on a copy of one Pittsburgh run against concert-db’s feed.
- Cold run 2, the test of all the fixes together, was still running when I wrote this.