Canopy Season
• Building a vendor-facing event directory when the data doesn't exist yet.
• Solo founder, designer, and operator · March–August 2026 build, launched July 2026
• Stack: Supabase (Postgres), Node/Express, Vite/React, Stripe, Resend, Kuberns Role: product, systems design, data architecture, UX, everything else.
• AI tools: Claude Chat/Code/Console, ChatGPT, Metricool, Flux, Notion, Wispr Flow.
• CanopySeason.com
• Solo founder, designer, and operator · March–August 2026 build, launched July 2026
• Stack: Supabase (Postgres), Node/Express, Vite/React, Stripe, Resend, Kuberns Role: product, systems design, data architecture, UX, everything else.
• AI tools: Claude Chat/Code/Console, ChatGPT, Metricool, Flux, Notion, Wispr Flow.
• CanopySeason.com
The problem
I vend at craft markets. The single hardest part of that business isn't making the work — it's finding out where to sell it, and whether it's worth the booth fee.
The information a vendor actually needs is a short list: when does the event happen, when do applications close, what does a booth cost, is it juried, how many people show up. That list doesn't exist in structured form anywhere. It's scattered across Facebook events, parks-department PDFs, a chamber of commerce page last updated in 2019, and Squarespace sites for markets that folded two seasons ago. Every consumer-facing event site with a database is built for organizers and affiliate marketers. Nobody had built the vendor side for vendors.
Canopy Season is: a searchable, vendor-facing directory of craft fairs, farmers markets, and festivals across all 50 states, DC, and Puerto Rico. At launch it carried roughly 8,400 published events, with a Pro tier, per-state data downloads, an application tracker with cost scenario modeling, and a robust vendor review system.
The product design was the easy half. The hard half was that I had to manufacture the dataset from scratch, keep it accurate across an annual refresh cycle, and do it alone. The three challenges below are the three places that nearly broke.
Challenge 1 — The scrape: escaping the aggregator trap
The problem. You can't just ask for "craft fairs in Michigan." The results that come back easiest are aggregators — sites that themselves republish event listings, usually stale, frequently duplicated, and almost never linking to the organizer. My early Washington and California passes came back looking plausible and needing near-total hand-editing. A separate Puerto Rico pass returned a dataset with roughly a 60% dead-link rate due to a government website. I deleted it rather than publish it.
The theory of the crime. The failure wasn't in extraction — the parsing was fine. The failure was upstream, in sourcing. A single-pass "go find events" instruction optimizes for finding something, and aggregators are the easiest something to find. I was grading the wrong step.
The fix. I split discovery from extraction and put a gate between them.
Pass 1 — sourcing only. Identify organizer-owned domains: municipal parks and rec, chambers of commerce, fairgrounds, market associations. No event data extracted at this stage at all.
Pass 2 — extraction only, from the validated source list, into a fixed 15-column CSV schema.
Validation gate between pass 2 and the database: column count, date parseability, URL shape. Fails loudly and stops; no partial imports.
Standing link health job Check URLs: DNS retries three times before writing a “dead” status, and retries 403s with a browser user agent — a lot of small-venue sites block default agents and look dead when they aren't. Dead links get flagged, never silently deleted.
Session budget of ≤120 rows, with large states partitioned geographically. Smaller batches measurably outperformed large ones on accuracy.
Result. States shipped clean on the first pass under the revised protocol. Puerto Rico was re-scraped post-launch and is now live.
v1 scrape sample CSV
v4 scrape sample CSV
v4 scrape sample CSV
Challenge 2 — Population and verification: finding the ceiling
The problem. Eight thousand events with a name and a URL is a spreadsheet, not a product. Vendors need descriptions, venue and neighborhood context, and above all, booth fees. Writing that by hand for 8,000 events, solo, is not a plan or a solution.
I built three agents to fill the gap:
Description Agent — generates event descriptions, with a web-search fallback when the source page is too thin to work from. Every output routes into an editorial review queue instead of publishing directly. This worked.
Basic Info Agent — venue and neighborhood enrichment. This worked.
Pricing Agent — extract booth fees into a structured Event Pricing Table. This one I paused, and that's the interesting part.
The two ceilings. The pricing agent hit two failures that were structural, not tuning problems.
The SaaS wall. Roughly 53% of events route their applications through third-party platforms — Zapplication, Eventeny, EventHub and similar. On those events the booth fee isn't on the public page at all. It's behind a login on a platform I don't have an account with. No model solves this, because it isn't an extraction problem; it's an access problem. Recognizing which kind of problem I had saved me weeks of aggravation.
Single-pass extraction failure. On the remaining events, pricing is expressed inconsistently — per day versus per weekend, per booth versus per linear foot, jury fee separate from booth fee, member versus non-member rates, early-bird tiers. A single extraction pass conflates them. I tested this across model tiers, expecting the failure rate to fall with capability. It didn't meaningfully change. That told me the problem was in my prompt structure, not the model, and that fixing it properly meant a multi-pass decomposition I didn't have runway for pre-launch.
The decision. I paused the agent rather than ship approximate prices. A wrong booth fee is worse than a missing one — a vendor budgets a weekend and a materials run against that number. In its place I shipped the structured pricing data model with a human approval pipeline, and made vendor-submitted pricing a first-class contribution path. The gap became a community feature instead of a liability.
Editor review sample
Admin review/approval sample
Challenge 3 — Editor workflow: never destroy the human work
The problem. Event data is annual. Every state has to be re-scraped each year or the directory rots. But by the time a re-scrape runs, that state's records have accumulated things a scrape can't reproduce: approved descriptions, human-verified pricing, vendor reviews, hand-corrected dates. A naive re-import overwrites all of it.
This became the hardest constraint in the system. The import pipeline had to be able to update machine-generated fields, and structurally incapable of touching human-curated ones.
The fix. A fuzzy-match upsert importer with three tiers and a hard lock.
Incoming rows match against existing events on normalized name, date, and location.
≥0.85 confidence → auto-match; machine fields update, curated fields untouched.
0.60–0.85 → written to an Import Review Queue for a human decision. Ambiguity becomes a work item, not a coin flip.
<0.60 → treated as a new event.
Provenance locks on curated Event Pricing rows. An import cannot silently delete or overwrite them. It errors and stops.
Two smaller mechanics do most of the day-to-day work. Existing rows a re-scrape didn't find get flipped to Unverified This Cycle rather than deleted — a machine claim has to be re-earned each season, but it's never destroyed on a silence. And no import commits itself; the run completes, reports, and waits for an explicit go-ahead. Both are cheap. Both mean the failure mode is a stale flag instead of a hole in the dataset.
The bug worth telling. The Washington dry run produced false 100% matches. The cause was the string similarity function: Token Set Ratio scores a subset as a perfect match, so "Ballard Farmers Market" and "Ballard Farmers Market Holiday Craft Fair" both scored 100. Two genuinely distinct events would have merged into one, and the smaller one's curated data would have been absorbed and lost. The fix was Token Sort Ratio — still word-order insensitive, but not subset-forgiving.
The part that matters isn't the fix. It's that nothing reaches the database without a dry run first, which is why this was a bug report and not an incident.
CSV importer sample via Terminal and Code
Import review admin queue
Import review admin queue macro crop — Three actions, keyboard shortcuts
What it's actually like building this way
Most of this was built in collaboration with Claude — advisory sessions for architecture and scoping, Claude Code for execution. A few things I'd tell anyone attempting the same:
Drift is the default. Over a long session, output quality doesn't fail loudly — it decays. Fallback paths creep in and defensive checks multiply. Two ways to do the same thing appear, then three. I countered this with a short written set of principles carried into every session and enforced actively: no fallbacks, one correct path, surgical changes, fail fast. Restating them cost nothing. Not restating them cost days.
Guardrails are the interface, not a sign of distrust. Dry runs, validation gates, provenance locks, review queues — every one exists because an automated process will do the wrong thing confidently, at scale, in one pass. Designing the checkpoints is the design work. The subset-trap bug is the whole argument: the system caught it because I'd built somewhere for it to be caught.
Separate deciding from doing. Running an advisory instance for scoping and a separate execution instance, with a written handoff spec between them, eliminated most of my rework. When one context both plans and builds, it defends its own plan and forges ahead until you question a decision or see the output.
The skill isn't prompting. It's knowing what correct looks like and refusing anything else — my three decades of UX systems design experience proved absolutely invaluable. I could pause the pricing agent quickly and without agonizing because I've stood behind a table and budgeted against a booth fee. Domain knowledge is what turns an ambiguous output into an obvious reject.
And this is a systems design problem, not a coding one. Data model, provenance rules, human-in-the-loop states, the failure behavior of every automated step. That's the same work as designing a component library — decide the states, decide what's allowed to change them, make the wrong path impossible rather than merely discouraged.
This project worked because I was building for myself first — and every automation decision got measured against a single question: could a vendor budget a weekend on this?