I built Go Do Stuff (internal slug event-buddy) because mainstream event discovery keeps failing in the same predictable ways. Eventbrite, Meetup, Luma, and SEO "things to do" directories rank by ticket volume and ad spend. They surface the same 5K run clubs, crowded rooftop cocktail mixers, commercial park yoga, and $65 tourist paint-and-sips. Keyword filters miss how an event actually feels socially and physically: whether you are making something with your hands at a table of 6 to 15 people where conversation happens naturally, or sitting in the dark at a comedy show talking to zero people.

Every city has a long tail of independent maker spaces, creative reuse depots, community print shops, tool libraries, fixit clinics, board game cafes, and volunteer stewardship crews. None of them buy SEO ads. Their schedules live across independent studio websites, local Substacks, and plain-text aggregator tables like 19hz.info. And on traditional platforms, search is stateless: rejecting ten events teaches the search bar nothing, while passing on one event because you are busy Friday makes a naive algorithm assume you hate that activity forever.

Go Do Stuff flips that model. I decided the core product should not be a static event database. The product is a continually improving, human-readable taste profile (preferences.md) of what kinds of real-world experiences a specific person finds worthwhile, paired with an autonomous live-web scouting agent that hunts down verified local events matching those mechanics. The live app is at go-do-stuff.com ↗. The source repo is private, so this post includes the schemas and architecture inline. A companion post covers how we cut median search latency by 53% and cost by 20%: Optimizing the Go Do Stuff Agent →.

1. The Heart of the System: preferences.md

I did not want to lock user taste inside opaque vector embeddings or black-box collaborative filtering weights. Every user profile in Go Do Stuff is stored as a structured, 7-section plain-text Markdown document (preferences.md).

The agent reads preferences.md before every search and updates it after every round of card feedback or post-event attendance check-in. In the browser (/preferences), the user can inspect the raw file, edit it directly, export it as .md, or maintain multiple named persona profiles (preference_documents) for different contexts like "Solo Weeknight Maker", "Saturday Outdoor Volunteering", or "Visiting Chicago".

Each of the seven canonical sections serves a specific job during planning and filtering:

  • 1. Current Reframed Goal: A single-sentence blockquote thesis (> ...) synthesizing what the user is hunting for right now, shown at the top of the Discovery Board.
  • 2. Defaults & Hard Constraints: Default city and neighborhood radius (for example, Oakland plus Berkeley, Emeryville, and Alameda), primary social goal, and demographic filter.
  • 3. Hard Exclusions (Semantic Category Filters): First-class semantic filters rather than literal keyword blocks. Excluding "run clubs", "rooftop parties", or "passive spectator events" also strips out a "5k social jog", "skyline sunset mixer", or "sit-down comedy showcase".
  • 4. Latent & Experiential Preferences (The "Why"): A Markdown table (Preference Dimension | Confidence | Origin | Supporting Evidence & Quotes) capturing cross-cutting mechanics like participatory doing over watching, small groups of 6 to 15 people, shared physical tasks, and DIY or offbeat character.
  • 5. Positive Interests & Anchors: Activity domains and concrete local venues the user already likes (such as East Bay Depot for Creative Reuse, The Crucible, or It's Your Move Games), used to craft high-precision Exploitation queries.
  • 6. Uncertain Hypotheses & Untried Candidates: Split into Planned / Upcoming events needing a post-event follow-up and a Candidates Not Yet Tried strong-fit backlog of recurring local series to test in future stacks.
  • 7. Event & Feedback History: Ground-truth log split into semantic buckets: Attended & Enjoyed, Planned / Added to Calendar, Positive Interest, Logistical Pause / Not Right Now, Attended Previously (Mixed / Goal Mismatch), and Rejected (Aesthetic / Fit Mismatch).
preferences.md (evolved profile after 2 feedback rounds) markdown
## 1. Current Reframed Goal
> You want hands-on, indoor maker workshops and craft/artisan studios in Oakland/East Bay
> (sewing, ceramics, printmaking, woodwork, DIY repair) where you learn tactile skills
> alongside a small group of local makers, with tabletop games as a secondary social anchor.

## 2. Defaults & Hard Constraints
- **Default Location:** Oakland, CA (and nearby East Bay: Berkeley, Emeryville, Alameda)
- **Primary Goal:** Meet people / genuine social connection
- **Demographic Filter:** Adult-oriented (exclude kid/family holiday events)

## 3. Hard Exclusions (Semantic Category Filters)
- **Run clubs** / fitness social runs _(Explicit)_
- **Rooftop parties** / generic skyline or cocktail socials _(Explicit)_
- **Purely passive spectator events** (shows, concerts, tours where you don't talk) _(Strong dislike)_
- **Contrived group activities** (e.g., citywide bicycle scavenger hunt) _(Inferred from Round 1)_

## 4. Latent & Experiential Preferences (The "Why")
| Preference Dimension | Confidence | Origin | Supporting Evidence & Quotes |
|---|---|---|---|
| **Participatory (doing > watching)** | Strong (High) | Explicit + Attended | Liked *Free Oakland Up* and *The Crucible*; positive on *Beginner Sewing*. |
| **Small group (6-15 people)** | Strong (High) | Explicit + Inferred | Intimate studio sizes where talking to neighbors is natural. |
| **Shared task** | Strong (High) | Explicit + Inferred | Working on a physical object or game removes social friction. |
| **Outdoor Stewardship** | Nuanced (Hold) | Feedback Round 2 | "Not super into outdoor stewardship this week... keep for future." |

## 5. Positive Interests & Anchors
- **Social practice / creative reuse** (*Free Oakland Up*, *East Bay Depot for Creative Reuse*)
- **Maker spaces & workshops** (Woodworking, textiles/sewing, ceramics, printmaking, fixit clinics)
- **Tabletop & board game drop-ins** (*It's Your Move Games & Hobbies, Temescal*)

## 7. Event & Feedback History
- **Attended & Enjoyed:** *Free Oakland Up* ("neat"), *The Crucible Industrial Artisan Fair* ("cool")
- **Logistical Pause (Valid Later):** *Outdoor Stewardship* (passed for Oct 31 window only)
- **Attended Previously (Goal Mismatch):** *HellaFunny comedy* ("fun but not social; I don't talk to people")
- **Rejected (Do Not Repeat):** Round 1 passive concerts, tours, and bike scavenger hunt

2. Why a Card Stack Instead of a Chatbot

We originally built Event Buddy as a conversational terminal CLI (uv run buddy in mvp/cli.py and mvp/agent.py) where a Gemini agent called search_local_events and update_preferences against a local preferences.md file. The conversational loop proved the thesis, and early user testing exposed two UX flaws.

First, triaging events is an action-oriented decision task, not an open-ended chat. Typing "I like #2 and #5, skip #1 because I'm busy Friday, and #3 sounds too corporate" into a chat input is tedious on desktop and painful on mobile. Second, dumping a grid of eight long event write-ups on screen at once causes cognitive overload. Users skim titles and miss the experiential details in "Why it fits you" and "Caveats".

We designed the web app around a tactile Risograph and indie zine aesthetic (#FAF5E8 cream paper, #FFFDF7 ivory cards, #1E242B ink borders with hard offset shadows) that feels like flipping through hand-picked neighborhood flyers. The app runs as a closed loop across three workspaces:

  • 1. My Taste (/preferences, PreferencesStudio.svelte): New users (needs_onboarding = true) get a 3-question starter popup covering location/radius, favorite activities/vibes, and exclusions. Submitting it calls the Taste Profile Coach, which writes a complete 7-section preferences.md, auto-titles the profile, and shows a diff summary box. The coach follows a strict 1-question budget on Turn 1 and stops asking unprompted questions on Turn 2+ so users are never trapped in an interview.
  • 2. Find Stuff (/discover, DiscoverBoard.svelte): Shows the active profile pill and 1-sentence goal banner, accepts natural-language or voice-dictated prompts plus quick window chips (This Weekend, Tonight, This Week, Surprise Me), and streams a 4-stage SSE progress tracker (Planning your search -> Searching for events -> Checking events -> Building your event stack). Results are dealt one card at a time (Option 1 of N) with four semantic reaction buttons (I'm Going!, Save for Later, Bad Timing, Not for Me) and an optional voice or text "Why?" note.
  • 3. My Plans (/schedule, ScheduleView.svelte): Closes the real-world loop across three tabs: Going Next (chronological schedule with 1-click Google Calendar export links), Saved for Later (backlog of events and recurring series), and How Did It Go? (post-event check-ins once is_past_due is true, recording Attended & Enjoyed, Attended (Mixed / Not Social Enough), or Didn't End Up Going back into Sections 4 and 7 with highest weight).
closed-loop-workflow.txt text
1. MY TASTE (/preferences)
   3-step starter -> Taste Profile Coach (1-question budget) -> preferences.md
   (Entity grounding looks up named local venues via Google Search + URL Context)
                 |
                 v
2. FIND STUFF (/discover)
   Prompt or date chip -> Plan -> Parallel Grounded Search -> Verify -> Card Stack (5-8)
   Per-card reaction: [I'm Going!] [Save for Later] [Bad Timing] [Not for Me] + "Why?" note
   End-of-stack Wrap-Up Card: "Update Profile Only" or "Update Profile & Deal Fresh Stack"
                 |
                 v
3. MY PLANS (/schedule)
   Going Next (1-click GCal) / Saved for Later / How Did It Go?
   Verified attendance outcomes feed back into Sections 4 & 7 with highest weight
Deep Dive: Anatomy of a single event card in the stack

Every card in DiscoverBoard.svelte renders six structured elements so the user can judge the social and logistical fit in a few seconds:

At the bottom of the stack, the final card is a Wrap-Up Summary showing all batch reactions and an overall steering note input. Clicking "Update Profile & Deal Fresh Stack" folds the batch evidence into preferences.md, shows the bulleted diff of what changed, and immediately launches a fresh search round using the updated goal, exclusions, and already_seen list.

  • Event Visual Banner (EventVisual.svelte): The scraped venue flyer or OpenGraph image (or a Risograph illustration fallback) paired with a bucket badge (Core Match, Adjacent Pick, or Wildcard Pick).
  • Direct Hyperlinked Title: Opens the canonical venue or event listing (source_url) in a new tab.
  • Logistics Strip: Day and date label (Saturday, Nov 7, 2026 or Recurring Weekly), start/end time, venue and neighborhood, and exact price (Free, $20, Sliding scale $10-$25).
  • What Happens (description): Concrete, non-promotional copy (zero marketing buzzwords and zero em-dashes) explaining what you physically do and how conversation happens.
  • Why It Fits You (why_it_fits): A 1 to 2 sentence tie-back to specific dimensions and anchors in the user's preferences.md.
  • Caveats & Verification (caveats): Honest social or logistical friction, such as arriving 15 minutes early for a popular drop-in workbench or confirming a studio materials fee.

3. The 60 / 30 / 10 Discovery Split

I wanted the agent to avoid two opposite failure modes: pure exploitation creates a repetitive filter bubble where a user only sees pottery classes at the same studio every week, while pure random exploration feels broken and ignores their taste. Every SearchPlanSchema and StructuredDiscoveryOutput stack is partitioned across three discovery buckets:

  • 60-70% Exploitation (Core Match): High-confidence queries targeting explicit positive interests and known anchor venues from Section 5 of preferences.md (such as specific Oakland ceramics studios, creative reuse centers, and board game cafes).
  • 20-30% Adjacent Exploration (Adjacent Pick): Neighboring experiential domains that share the same social and physical mechanics from Section 4, even if the user has never named that medium. Someone who likes beginner sewing and tabletop games because they enjoy small groups of 6 to 15 people doing a shared tactile task gets a community linocut printmaking drop-in, a bookbinding night, or a neighborhood Fixit Clinic.
  • 10% Plausible Wildcard (Wildcard Pick): A serendipitous lateral leap outside the user's routine that still obeys every Hard Exclusion in Section 3 and carries a defensible hypothesis in why_it_fits (like an amateur astronomy lecture and telescope viewing or a community zine swap).

4. Four Specialized Agent Workflows

Rather than forcing one monolithic prompt to handle interviewing, live web search, JSON card extraction, and profile editing all at once, we split the backend intelligence into four specialized workflows running on gemini-3.8-flash via the google-genai SDK:

  • A. Structured Discovery Pipeline (execute_discovery_pipeline on POST /api/discovery/search/stream): Runs Stage 1 JSON query planning, Stage 2 parallel GoogleSearch workers, Stage 3 StructuredDiscoveryOutput card curation, and Stage 4 SSE delivery.
  • B. Taste Profile Onboarding Coach (run_preferences_onboarding_turn on POST /api/profile/documents/{id}/chat): Strictly forbidden from recommending weekend event schedules. Its sole job is maintaining preferences.md via OnboardingTurnSchema (reply, should_update_document, updated_markdown, change_summary, reframed_goal, updated_title). It enables google_search and url_context only for entity lookup: if a user says "I loved going to Free Oakland Up and The Crucible", the coach looks up those local organizations on the web and populates Section 4 and Section 5 without asking "What is Free Oakland Up?".
  • C. Feedback & Outcome Synthesizer (synthesize_feedback_into_preferences): Triggered by /api/discovery/batches/{id}/apply-feedback/stream and /api/events/{id}/outcome. Takes structured reaction reports and folds them into all seven sections (PreferenceUpdateOutput), streaming a human-readable change_summary diff back to the UI.
  • D. Autonomous Multi-Turn Tool-Calling Agent (EventBuddyAgent in mvp/agent.py and UserScopedAgentRunner in api/app/services_agent.py): Powers the terminal CLI (uv run buddy) and Classic Chat (/chat) using a ReAct loop (up to 6 tool iterations per turn) with native function declarations (search_local_events, update_preferences, read_preferences) plus automatic <current_preferences_md> injection on every turn so manual user edits take effect immediately.
discovery-pipeline-stages.txt text
Stage 0  already_seen dedupe (UserEvent saved + CandidateEvent rejected + Section 7)
Stage 1  Search planner -> SearchPlanSchema JSON
         (exploitation / adjacent / wildcard queries + concrete date_window)
Stage 2  Live Google Search grounding + vertexaisearch redirect resolution
Stage 3  StructuredDiscoveryOutput JSON (5-8 verified CandidateEvent rows)
         Anti-hallucination: source_url must exist in resolved grounding chunks
Stage 4  SSE cards_ready immediately, then async multi-tier flyer hydration

5. Inside the Structured Discovery Pipeline

When a user clicks Find Events on /discover, the backend runs execute_discovery_pipeline (api/app/services_discovery.py and mvp/search.py), parameterized by a DiscoveryConfig Pydantic model so production and offline evaluation call the exact same code path. Before Stage 1 even runs, the pipeline builds an already_seen deduplication list by combining saved UserEvent titles, rejected CandidateEvent rows (reaction_status == "not_interested"), and Section 7 of preferences.md so the agent never deals the same event twice.

Stage 1 (build_plan_prompt -> SearchPlanSchema) reads preferences.md, already_seen, the user prompt, and the current local date. It resolves relative windows like "this weekend" or "tonight" into concrete calendar dates (date_window), writes a 1-sentence reframed_goal for this search, and generates targeted Google queries split across exploitation_queries, adjacent_queries, and wildcard_queries.

Stage 2 (execute_event_search) executes live Gemini Grounding with Google Search (types.Tool(google_search=types.GoogleSearch()), plus optional UrlContext in 2-pass deep mode). It prioritizes independent venue calendars, maker spaces, creative reuse depots, community colleges, and local aggregators over generic ticket directories, then extracts grounding_chunks and resolves https://vertexaisearch.cloud.google.com/grounding-api-redirect/... URLs into canonical venue URLs (GroundingSource.resolved_uri) via parallel httpx workers.

Stage 3 (build_structure_prompt -> StructuredDiscoveryOutput) curates 5 to 8 verified cards under strict rules: every source_url must come from the resolved grounding chunks, the venue must physically sit inside the target location and date_window (or be marked is_recurring = true), any candidate violating Section 3 Hard Exclusions or prompt constraints (budget, indoor, sober, daytime) is stripped, and intra-batch diversity enforces variety across venues and activity subtypes. The moment Stage 3 finishes, Stage 4 emits event: cards_ready over SSE and spawns background workers (fetch_event_image_url) to stream event: card_image updates as each flyer resolves.

Deep Dive: How feedback reports and multi-strategy flyer scraping work

When a user finishes rating a stack, synthesize_feedback_into_preferences receives a structured report that separates strong positives from logistical pauses. That separation is why Section 7 works: passing on a Saturday morning habitat restoration because of rain never penalizes outdoor stewardship for future weeks.

Similarly, scraping event flyers in Stage 4 required a 4-tier fallback chain in fetch_event_image_url: standard Chrome headers first, then a retry with WhatsApp/2.23.20.0 link-preview bot headers for sites like Resident Advisor that return Cloudflare 403 to browsers, then a 1-hop outbound link extractor for plain-text aggregator tables like 19hz.info using a title match, and finally a Microlink API fallback.

feedback-report.txt text
Discovery Batch Query: What should I do this weekend? (Oakland, CA | Fri Nov 6 - Sun Nov 8, 2026)
Overall User Batch Note: Looking for more indoor tactile crafts and fewer outdoor walks.
- [GOING (Added to Schedule - Strong Positive)] Sunday Chess Drop-In (It's Your Move Games) | "Easy way to play casual games and chat"
- [INTERESTED (Saved to Backlog - Positive)] Beginner Linocut Printmaking (Kala Art Institute)
- [BAD TIMING / HOLD (Do NOT penalize activity type)] Sausal Creek Habitat Restoration | "Raining this Saturday, keep for spring"
- [NOT FOR ME (Rejected - Aesthetic / Social Mismatch)] Downtown First Friday Street Fest | "Too crowded and loud to actually meet anyone"

6. Technical Stack & Resilient SSE Streaming

The frontend (web/) is built with SvelteKit 2 and Svelte 5 Runes ($state, $derived, $effect), compiled via @sveltejs/adapter-static into a static SPA served directly by FastAPI in production. Native browser SpeechRecognition / webkitSpeechRecognition provides zero-latency voice dictation across search bars, card reaction notes, and onboarding chat.

The backend (api/ and mvp/) runs on Python 3.12, FastAPI, SQLAlchemy 2, and Alembic, using SQLite in WAL mode (event_buddy.db) for zero-config local development and Neon Serverless PostgreSQL in production (users, preference_documents, discovery_batches, candidate_events, user_events, chat_sessions). Auth uses Argon2id password hashing, SHA-256 HttpOnly session cookies, 6-digit Resend email verification codes, and per-user daily search guardrails.

One piece of plumbing mattered a lot on mobile: _InflightStreamHub in api/app/routers/discovery.py. If a user refreshes their browser tab or their phone connection blips mid-search, _InflightStreamHub coalesces the reconnected SSE request onto the already-running background search thread and replays all emitted status, plan, and card events from memory so the search is never lost or double-billed.

A single multi-stage Dockerfile (Node 22 Alpine frontend build -> Python 3.12 Slim + uv backend) powers three Google Cloud Run workloads: the event-buddy web service, the event-buddy-migrate Alembic job, and the event-buddy-eval benchmark job. Deployments run ./scripts/check-active-users.sh before traffic cutover so an active user's SSE stream is never severed.

system-architecture.txt text
SvelteKit 2 / Svelte 5 SPA (web/src/)  --REST + SSE-->  FastAPI Backend (api/app/)
  /discover, /preferences, /schedule, /chat               _InflightStreamHub SSE coalescing
  Web Speech API dictation + progressive cards            Argon2id + SHA-256 HttpOnly cookies
                                                                  |
                                        +-------------------------+-------------------------+
                                        v                                                   v
                        Core Agent & Search (mvp/)                          SQLAlchemy 2 (SQLite WAL / Neon PG)
                        services_discovery.py + mvp/search.py               users, preference_documents,
                        Gemini 3.8 Flash (GoogleSearch + UrlContext)        discovery_batches, candidate_events,
                        Parallel workers + 4-tier flyer scraper             user_events, chat_sessions

7. Why This System Was Uniquely Hard to Optimize

Understanding what Go Do Stuff does makes it obvious why standard "make your LLM faster and cheaper" advice would have broken the product. First, you cannot cache live local events across users: every search depends on a user's specific 7-section preferences.md (their neighborhood, semantic exclusions, studio anchors, and the exact events they already rejected in Section 7) plus relative time windows like "tonight" or "this Saturday afternoon".

Second, you cannot skip live web search grounding. An ungrounded model will happily hallucinate plausible pottery classes at real Oakland studios on dates that do not exist, or link to dead homepages. Every recommended card has to be backed by a live Google Search grounding chunk with a verified date, time, price, and resolved canonical URL.

Third, broadening queries saves money right away while destroying the hyper-local magic. When we tried collapsing 8 to 10 specific queries into 4 broad OR queries, cost dropped 58% and latency dropped 32%, and the stack lost 11-1 in blind side-by-side judging because broad queries missed Section 5 neighborhood studio anchors and surfaced generic city-wide listings.

How we built an automated 4-layer evaluation harness (buddy-eval) and re-architected the pipeline into a 1-worker-per-query parallel map-reduce system is covered in full in the companion write-up: Optimizing the Go Do Stuff Agent →.