The Bird Feed
Twin Cities event recommender · 29 Oct 2026

The Bird Feed Blueprint

How the system fits together, what the data looks like, and what gets built next. Milestones 1 to 3 are done and M4 is under way: a daily Lambda ingests 31 sources (venue calendars on a dozen platforms, Racket's weekly roundups and Minnesota Monthly's calendar) into Neon, about 1,950 upcoming listings, and enrichment profiles the acts playing in the next eight weeks. Two pieces of later milestones are live too: the first version of the site (M6) and our own categories and genres (M7). The plan is now venue-first: venue calendars are the record, and publications like Racket point to the venues and shows we don't track yet. Requirements are in docs/requirements.md.

From venue calendars to a short list of picks

Built and running Current milestone Planned

A daily run

  1. At 6:15am Central, EventBridge Scheduler invokes birdfeed-ingest.
  2. The Lambda reads the Neon connection string from /birdfeed/database_url.
  3. Each active source is fetched with an honest BirdFeed/… user agent and parsed by its adapter. Prose sources go through LLM extraction in the same run.
  4. Listings are upserted through Neon's pooled endpoint. Unchanged rows are counted but not rewritten.
  5. Enrichment takes the acts with shows in the next eight weeks, soonest first: MusicBrainz match, then Tavily web search and YouTube for acts MusicBrainz can't place. A profile is written only from quotes found in the pages read.
  6. Planned: a gap check turns missing genres, descriptions, IDs and sources into research tasks. The agent works through them within its budget.

A deploy

  1. A push to main that touches pipeline/, config/ or infra/pipeline/ starts deploy.yml.
  2. Tests run, then build_lambda.sh builds the zip (x86_64, Python 3.13).
  3. terraform apply runs as IAM user birdfeed-github-deploy, since the project's SCPs block OIDC.
  4. The workflow invokes the Lambda with {"action": "migrate"}, and Alembic runs against Neon's direct (unpooled) endpoint.

Who, where, what it's like, and how we know

Built so far: sources, venues and venue_aliases, events (really per-source listings), raw_pages and raw_labels, and the performer tables: performers, performer_aliases, event_performers and performer_profiles. This is the target model. Each table is labelled with the milestone that built it or will add it.

Lines are foreign keys. The links from events to festivals, event_organizers and picks aren't drawn, and neither are the links from performers to performer_profiles and memberships. Tables marked ANY ENTITY attach to events, performers, venues or sources through entity_type + entity_id.

Where data comes from

sourcesBuilt · M2

A feed we fetch. Becomes the source of truth instead of sources.yaml.

  • adapterical, squarespace, racket, …
  • settingsJSONB: url, title_time_prefix, …
  • venue_idnull for multi-venue sources like Racket
  • statusproposed, active, paused, broken
  • discovered_byconfig, you, or a research task
  • last_fetched_at, last_error, consecutive_failures
raw_pagesBuilt · M2

Index of fetched pages cached in S3. Extraction re-runs only when the hash changes.

  • source_id, url
  • s3_key, content_hash
  • fetched_at, extracted_at
listingsBuilt as events · M5 renames

One event as one source lists it. Exactly what's there today, plus a link to the canonical event.

  • source_id, external_idunique together
  • event_id newthe de-duplicated event
  • venue_id, title, starts_at, time_known, prices, urls
  • performer_names newas listed, before resolution
  • raw, content_hash, first_seen_at, last_seen_at, removed_at new
raw_labelsBuilt · M2

Genre or category text exactly as a source gave it. Kept as evidence, never shown as our own classification.

  • listing_id, label
  • fieldwhere it came from: category, description, title

What happens, where, and who's in it

eventsM5

The real-world happening. Several listings from different sources point to one event.

  • venue_id, series_id, festival_id
  • title, starts_at, ends_at, time_known
  • statusscheduled, cancelled, removed, sold_out
  • price_min, price_max, is_free, age_policy
  • primary_listing_idwhich listing supplies the display fields
seriesM5

A recurring thing: trivia Tuesdays, a monthly open mic. Occurrences are events.

  • venue_id, name
  • rruleiCal recurrence rule
  • host_performer_id
venuesBuilt · M2

A place. Created from config, by extraction, or by the research agent.

  • slug, name, address, url
  • kindclub, bar, theater, gallery, brewery, park, library…
  • neighborhood, city, lat, lng
  • capacity, age_policy, accessibility, price_tier
  • linksJSONB: socials, MusicBrainz place, Wikidata
  • statusactive, needs_review, closed
  • field_provenanceJSONB: field → provenance id
venue_aliasesBuilt · M2

Every way a source names a venue. Matching normalizes case and "the".

  • venue_id, aliasunique, normalized
  • source_idwhere the alias was seen
performersBuilt · M3

Any act: band, solo musician, DJ, comedian, speaker, theater company, or a person who plays in groups.

  • name, sort_name, kind, disambiguation
  • origin, active_years
  • musicbrainz_id, wikidata_id
  • linksJSONB: website, Bandcamp, Spotify, YouTube
  • status, field_provenance
event_performersBuilt · M3

Who's on the bill, in order.

  • event_id, performer_id
  • roleheadliner, support, host, dj, member
  • instrumentsthat night's, e.g. a trio's listed players
  • position, confidence, provenance_id
performer_aliasesBuilt · M3

Spellings, old band names and stage names, used when resolving listings.

  • performer_id, alias
performer_profilesBuilt · M3

What a performer sounds like, extracted from all their descriptions. Bio trivia stays out.

  • performer_id
  • sound_summaryone or two sentences; embedded as the sound aspect
  • comparisonsJSONB: "for fans of" text + resolved performer ids
  • contextJSONB: origin, label, active years
  • evidenceJSONB: fact → source URL + quote
  • sourcesJSONB: the pages read, by kind
  • inputs_hash, model, provenance_id
membershipsM7

Who plays in a group, so a drummer can be followed across trios.

  • group_id, person_idboth performers
  • instruments, active_from, active_to
  • provenance_id
organizersM5

Promoters, presenters and collectives. Often a better taste signal than the venue.

  • slug, name
  • kindpromoter, presenter, collective, nonprofit
  • links, status, field_provenance
event_organizersM5

Who's behind an event, as opposed to who's on stage.

  • event_id, organizer_id
  • rolepresents, promotes, sponsors
  • provenance_id
festivalsM5

A multi-day happening that groups events, possibly across venues: a music festival, the Fringe.

  • slug, name, url
  • starts_on, ends_on
  • organizer_idoptional

What it's like

tagsBuilt · categories and genres

Our taxonomy. Top level is a fixed set of categories; below are open genre tags.

  • slug, name, parent_id
  • levelcategory, genre, instrument, mood, setting
  • statusaccepted, proposed, merged
  • merged_into_idold tag kept as an alias
label_mappingsBuilt · categories and genres

How a raw label translates to our tags, decided once and reused.

  • labelnormalized
  • tag_id, weight, provenance_id
taggingsBuilt · categories and genres

Tags on events, performers and venues. Events inherit performer tags at ranking time instead of copying them.

  • entity_type, entity_id, tag_id
  • weight0 to 1
  • originsource label, llm, agent, you
  • provenance_id
descriptionsM7

Several per entity. The app shows a generated summary, or an excerpt plus a link.

  • entity_type, entity_id
  • kindsource, editorial, reference, generated
  • text, source_url, retrieved_at
  • based_onfor generated ones: description ids used
  • provenance_id
embeddingsM8

Vectors for similarity ranking. Each row records its model, so switching models means re-embedding, not a schema migration.

  • entity_type, entity_id, aspect, model
  • aspectsound first, built from the profile, never the raw bio
  • vectorpgvector
  • content_hashre-embed only on change

How we know, and what you think

provenanceM7

Where a fact came from. Hand-entered or approved facts are never overwritten by automation.

  • actoradapter, llm, agent, you
  • tool, model, source_url
  • retrieved_at, confidence
  • research_task_id
  • review_stateauto, pending, approved, rejected
research_tasksM7

The agent's queue. Created by the gap check after each ingest.

  • kindenrich_performer, enrich_venue, resolve_performer, discover_source
  • entity_type, entity_id, missingfields to fill
  • priorityfrom next event date
  • statusqueued, running, done, no_result, needs_review, failed
  • attempts, retry_after, tokens_used, tool_calls
feedbackM8

Thumbs on events, which also count weakly toward their performers, venue and tags.

  • event_id, value+1 or −1
  • note, created_at
taste_profileM8

Your written description of what you like, versioned.

  • text, updated_at
picksM8

Recommendations generated when you open the app, cached so reopening doesn't re-run the LLM.

  • generated_at, windowdate range covered
  • inputs_hashprofile, feedback, events; a change invalidates
  • event_id, rank, reason, model

Polymorphic attachments

Tags, descriptions, embeddings and research tasks use entity_type + entity_id with a check constraint, so a new entity type doesn't need new join tables. The cost is no foreign keys, so deletes clean these up in code.

Field-level provenance

Columns on venues and performers that research fills in record their provenance in a field_provenance map. A researched value never overwrites one you set or approved.

Times and identity

Times stay timezone-aware in UTC with the original zone. Slugs are stable and human-readable, and external IDs (MusicBrainz, Wikidata) are the preferred keys for matching.

Milestones

Venue-first since 28 Oct: venue calendars are the record of what's on, and publications like Racket are discovery signals that point to venues and shows we don't track yet. Each step makes the next one testable: coverage before de-duplication, clean entities before research, something to look at before tuning taste. Performers and their profiles were pulled forward into M3.

M1Done

Data flowing

  • iCal and Squarespace adapters for Berlin, Bunker's and Driftwood. Driftwood's start times are parsed from titles.
  • Alembic schema, CLI, tests and CI.
  • AWS bootstrap and pipeline: a daily Lambda, SSM parameters, schedule and budget alert, deployed from main by GitHub Actions.

Done: 27 Oct 2026.

M2Done

Sources and venues in the database, and Racket

  • Sources and venues live in the database. birdfeed seed and export keep the YAML path, and birdfeed sources add adds a source of a known feed type without a deploy.
  • venue_aliases, raw_labels and the S3 raw-page cache, so unchanged pages aren't re-extracted.
  • Racket's weekly roundups from RSS, split into days in code and extracted one day per LLM call on Bedrock. Unknown venues are created as needs_review.
  • Every LLM call's tokens and cost recorded in llm_calls.

Done: 28 Oct 2026.

M3Done

Performers and profiles (pulled forward)

  • Calendar titles split into acts, hosts and formats. performers, performer_aliases and event_performers with roles and billing order.
  • MusicBrainz candidates judged by the LLM against the listing's context, with a confidence score.
  • For acts MusicBrainz can't place: Tavily web search and YouTube channel lookup. The LLM picks the act's own pages, which are read after a robots.txt check. Social accounts are kept as links, not read.
  • Profiles (sound summary, genres, comparisons, lineup, origin) written only from quoted evidence that is checked in code. A daily run covers acts with shows in the next eight weeks.

Remaining: lineup members as person performers with memberships (moved to M7).

M4In progress

Venue-first coverage

  • Platform adapters, one per ticketing or calendar platform, so one adapter covers every venue on it. Built: The Events Calendar, Rockhouse Partners and recurring-show loops on WordPress, Squarespace, SpotHopper, VenuePilot, Opendate, Shopify, First Avenue, schema.org JSON-LD (which covers Live Nation's venue pages), and page, which reads hand-built calendar pages with the LLM. Still to come as venues need them: DICE, Etix, SeeTickets and Eventbrite.
  • Venue discovery (not started; so far done by hand from Racket's and Minnesota Monthly's venues without sources): a Racket listing at a venue we don't track starts venue research. Find the official site and calendar, test-fetch it through an existing adapter, and propose it as a source with sample events. Proposed sources always wait for your approval.
  • Official show pages (not started): for a publication listing, find the show's page on the venue's site to fill in start time, price, ticket link and full lineup, and to match it to the venue calendar's event later.
  • Breadth beyond music clubs: Green Room, First Avenue's rooms, Bell Museum, Walker Art Center, the Museum of Russian Art, Children's Theatre, Southern Theater and Sabathani Community Center are in. Minnesota Monthly's calendar is ingested privately, under its terms, as a discovery signal. Still to come: libraries, parks, breweries and Reddit roundups.

Done when: a typical week is complete from venue calendars alone, and Racket mostly adds write-ups.

M5Started

Events and data quality

  • Split events into listings plus canonical events, and de-duplicate across sources, helped by the official show pages from M4. Built first: cross-source matching (same venue and day, a shared act or title) sets same_as_id to the venue calendar's listing, and the site shows each show once, crediting every source.
  • series for recurring events. Mark removed listings and cancelled events.
  • organizers, event_organizers and festivals.
  • A CloudWatch alarm that emails on Lambda errors or when a source returns zero events.

Done when: one real-world show appears once, with every source that listed it and a resolved headliner.

M6Started

Read-only web app

  • Next.js on Vercel once pages per venue and performer need routing, reading through the same read-only role.
  • A week view with filters (date, venue, source), full-text search, and linked pages for venues, performers, organizers and festivals.
  • Data-quality views: listings per source, duplicate candidates side by side, venues needing review, proposed sources, sources with errors.
  • Built: v1 is live: static pages on Vercel whose listings and counts come from birdfeed-web, a read-only Lambda API behind Vercel's CDN. They share one look: a navy masthead, white pages, Libre Franklin, and maroon for what should stand out. The listings page filters by category and searches titles, venues, acts and genres.

Done when: you check what ingest did in the app instead of in SQL.

M7Started

Classification and the research agent

  • tags (category, genre, instrument, mood, setting), label_mappings, taggings, descriptions and provenance. Built: tags, label_mappings and taggings for 16 categories and their genres, from config/tags.yaml, with each source label mapped once by alias or by Haiku. Instrument, mood and setting tags are still to come.
  • Lineup members become person performers with memberships.
  • Gap check after each ingest writes research_tasks. SQS feeds a research worker that runs a Bedrock tool-use loop with MusicBrainz, Wikidata, page fetch and web search tools, within the $30/month budget.
  • Facts at or above the confidence threshold go live immediately. A review list holds the rest, plus proposed tags and auto-created venues.
  • deepeval sets for performer resolution and the accuracy of researched facts.

Done when: most performers with events in the next eight weeks have genres, a description and a listen link, and every fact traces to a source.

M8Planned

Recommendations

  • Accounts (a hosted sign-in such as Clerk) so likes and picks belong to someone.
  • A taste profile, plus feedback thumbs that also count toward performers, venues and tags.
  • A private calendar feed per user: the shows they save, subscribed once in Google, Apple or Outlook and kept up to date, with cancellations crossed out rather than dropped.
  • Embeddings from Cohere Embed v4 on Bedrock, stored in pgvector. The sound vector embeds profile text, not raw bios.
  • Hybrid search: filters, full-text and the sound vector, with an LLM turning a free-text query into filters plus a semantic query.
  • A ranking API Lambda, called by the app on demand: hard filters, then similarity, then an LLM re-ranks the top ~50 with one line on why each fits. Results are cached in picks.

Done when: the week view's top ten would mostly be your own picks.

M9Planned

Chat

  • The weekly picks page from M8 is the digest. An email version can come later, built on the same picks.
  • Chat over events, performers, venues and organizers, once ranking is trusted.
  • An MCP server over the same read-only API, so people can search with their own agents: listings by date, category, genre, venue or text, and performer and venue lookups, with excerpts and links only.

Done when: you ask it what to do on Friday before opening the week view.

Decisions, open questions and housekeeping

Decided 27 Oct

  • Review: researched facts above the confidence threshold go live automatically. New sources still wait for approval.
  • Web search: research gets a search API.
  • Research budget: $30 a month in model and search spend.
  • Organizers, promoters and festivals: modelled now, built in M3.
  • Digest: the app generates picks on demand, and the web interface comes first for exploration and a faster dev cycle. Email maybe later.
  • Embeddings: Cohere Embed v4 on Bedrock. Re-embedding everything costs cents, so the choice isn't locked in.
  • Performer profiles: structured extraction from descriptions instead of RAG over raw bios. Lineups become linked people.
  • Web app early: a read-only version comes right after M3 for exploration and checking data.
  • Extraction: runs inside the daily ingest Lambda.

Decided 28 Oct

  • Venue-first: venue calendars are the record. Racket is a discovery signal for venues and shows we don't track, not the backbone.
  • Performers first: performers and evidence-backed profiles were built ahead of de-duplication, since they're what makes a listing worth reading.
  • Web search: Tavily for performer research, with Brave as a fallback. Result lists are never stored: only our findings and the URLs of pages we read.

Still open

  • Embedding check (optional): before M8, compare Cohere Embed v4 with Titan Text Embeddings V2 on a few of your own likes.

Housekeeping

  • Merge the duplicate Bunker's performer created by the "Some Shittty Cover Band" typo.
  • Check whether Wikidata and Wikipedia accept requests from the Lambda. They refused the cloud session's egress; if they refuse the Lambda too, add a contact email to the user agent.
  • Rotate the deploy user's access key occasionally (two-key swap, documented in infra/README.md).
  • Watch Neon free-tier compute hours once the research agent and web app add load.