# Wasup — Linz Event Index Live at: https://wasup.at (canonical home; wasup.goedly.com is a legacy alias) An index of every public event in Linz, Austria (and ~25km around): concerts, theatre, sport courses, Vereine, markets, church fests, gym timetables - the long tail no portal has. Crawled from ~200+ sources, deduplicated, confidence-scored. Machine-readable spec: `/openapi.json` (RFC 9727 catalog: `/.well-known/api-catalog`). ## Semantics you must respect (hard contracts) - **null means unknown, never "no".** An event without a known category/geo/ end time is missing data. Hard filters never match unknowns. - **Exclusions are guarantees.** `exclude_categories`/`exclude_terms` are set logic applied BEFORE ranking - an excluded thing cannot appear, period. - **Every inferred attribute is an estimate with a certainty** (0..0.8 - capped by construction; ~0.2 = world-knowledge guess, ~0.35 = typical for the category, up to 0.8 with explicit textual evidence). - **`projected: true`** on an occurrence = a forward-projected repetition of an observed weekly/biweekly series (beyond what its source feed shows). Treat as "expected, unconfirmed". - **`time_unknown: true`** = the source stated only a DATE; `starts_at` shows midnight as a placeholder, not a real time. When available, `start_time_estimate` carries an LLM-estimated typical start time ({value: "HH:MM", confidence, evidence}) - an estimate, clearly not a confirmed time; the index also re-fetches detail pages to find real times. `ongoing: true` = the occurrence started before your window but is still running (exhibitions, festivals) - windows use OVERLAP semantics, not starts-only. - **Geography default**: results are gated to ~15 km around Linz; events with UNKNOWN location always pass the gate. Override with `near=lat,lon` + `radius=` (then unknown-location events are excluded - it's a hard filter); `radius=any` disables all distance gating even if `near` is also present. - **`confidence`** on results decays with staleness (missed re-confirmation cycles); `last_confirmed_at` says when a source last showed the event. Every search/list/feed defaults to effective confidence >=0.4. Set `min_confidence: 0` only when the user explicitly wants tentative or unverified hints. The MCP standard `search` tool is the narrow exception: exact title/venue/organizer lookup also returns lower-confidence index records, explicitly labeled `tentative confidence N`, so a temporarily stale record is not misreported as absent. Empty structured MCP searches report when the same filters have tentative matches and tell the caller to retry with `min_confidence=0`. - **`provenance_summary`** lists the reporting sources; `GET /v1/events/{id}` returns sanitized event fields, occurrences, and source name/URL/timestamp provenance. Raw append-only claim payloads and evidence snippets are never published. ## Querying (use this, it costs the index nothing) `POST /v1/query?limit=20` - body: any subset of the filter fields (JSON). Browse-only agent (can only GET)? Same filters as query params: `GET /v1/query?name=ball&tags=dance,elegant&importance=tags:1.0&limit=10` Result-shape params (query string on GET and POST): `sort=starts_at` for chronological (default `relevance` = certainty-aware match_score, NOT chronological; whole-event confidence only breaks ties), `distinct=event` by default for discovery questions (one result per event, represented by its next relevant occurrence); set `distinct=occurrence` only when dates themselves are the requested result. `offset=` pages through the complete ranked candidate set. **No API key needed for reads** (query, occurrences, events/{id}, feed.ics, changes) - anonymous access is rate-limited to 60 req/min per IP; a key (header `X-API-Key` or `?api_key=`) lifts the limit. Keys are required only for `/v1/search` (it spends the index's own LLM budget) and `POST /v1/reports`. HARD fields (set logic): `from_dt`, `to_dt` (ISO, naive = Europe/Vienna; a bare date in to_dt means the WHOLE day), `weekdays` (local day names such as `thursday`/`friday`), `near`+`radius` (geo circle), `min_confidence` (default 0.4; 0 explicitly includes tentative hints), `categories`, `exclude_categories`, `exclude_terms`, `name` (literal event-title lookup; `ball` also matches compounds such as `Maturaball`), `organizer` and `venue` (literal substrings in their own fields), `source` (literal reporting-source name or URL), `max_price`, `is_free`, `required_attributes`, `min_tag_match`, and the optional per-requested-concept `min_tag_concept_match` when `tags` are present. SOFT preference fields (ranked, never dropped): `age_min`+`age_max`, `gender_split_min` (0=all male..1=all female), `kid_friendly`, `newcomer_friendly` (open to strangers vs members-only), `solo_friendly` (normal to attend alone), `interaction_structure` (built_in = the format FORCES interaction: rotation/teams/pair work; optional; none = silent attendance ok), `outdoor`, `energy` (low|medium|high), `language` (de|en), `preferred_max_price`, `participant_count_min`, `participant_count_max`, `min_scale_confidence`, `sex_service_context` (true = the event happens at a commercial sex establishment - Bordell, strip club, swinger club - NOT mere 18+ nightlife; send `false` BY DEFAULT so these rank out of innocent queries, leave unset only when the user explicitly asks for that milieu; keep it a soft preference - in `required_attributes` it would also drop every event where the attribute is still unknown). Optional `importance`: `{attribute: 0..1}` (default 1.0 each). Attribute names for `importance` and `required_attributes` are: `age` (note: one name for the age_min/age_max pair), `gender_split_min`, `kid_friendly`, `newcomer_friendly`, `outdoor`, `solo_friendly`, `interaction_structure`, `energy`, `language`, `sex_service_context`, `event_scale`; `importance` also accepts `price` and `tags`. Price becomes a hard exact-fact constraint through `max_price`/`is_free`, not `required_attributes`. `min_scale_confidence` is meaningful only with `participant_count_min` or `participant_count_max`; sending it alone is invalid. Ranking combines **your importance x the stored certainty**, anchored at the coin flip: an event scores `0.5 + certainty/2` when it satisfies a preference, `0.5 - certainty/2` when it contradicts it, and `0.45` when the attribute is unknown - so confident matches rank first, weak guesses beat unknowns, unknowns beat contradictions, and nothing is silently dropped. The per-row `match_score` exposes the result. Add an attribute name to `required_attributes` to make it a hard filter instead (then unknowns are excluded - use sparingly, most events have estimated attributes only). `tags`: all jointly desired 1-3-word activity/topic/format/atmosphere concepts in one list. They match the one confidence-bearing event-tag collection with a calibrated local multilingual model. Each requested concept keeps its own evidence. Multi-concept requests average the two strongest supporting tags and combine requested concepts with a harmonic mean, so weak coverage is a bottleneck. An order-invariant combined phrase is a context diagnostic only: it can reduce the harmonic result by at most 10% and can never raise it. The returned `joint=true`, `role=combined_phrase_context` row is therefore not the final score; read `tag_match` for the final result, `tag_weakest_concept_match` for the weakest requested concept, and `tag_context_match` for that diagnostic. Tags rank softly by default. Set `min_tag_match` only when the combined concepts are a hard requirement; add `min_tag_concept_match` when every requested concept needs its own hard floor. Exact exclusions never use embeddings. When tags are combined with secondary soft preferences such as `preferred_max_price` or estimated crowd size, semantic fit leads by default; the secondary fields refine it. Use `importance` to override that balance only when the user's wording clearly makes another preference equally or more important. When transferring accepted search results to `get_calendar_link`, inspect `time_unknown`. Either leave such rows out of the accepted set or explicitly set `include_time_unknown=true`; the default timed-only feed cannot contain date-only rows. Fine print an agent should know: - Windows use overlap semantics: anything still running at `from` matches (flagged `ongoing`); a null `ends_at` is treated as ending at `starts_at`. - Every result carries `price={min,max,currency,confidence,basis,source_url}`. `basis=stated` is an exact public fact and may satisfy `max_price`/`is_free`; `basis=estimated` participates only in soft `preferred_max_price` ranking. Unknown is represented by null min/max. - Every result carries `event_scale={estimated_participants,plausible_min,plausible_max,band, confidence,basis}`. Participant-count ranges are soft unless `required_attributes=["event_scale"]`. - `match_score` orders results; it is NOT a percentage. Certainties are capped (0.8) and unknowns score a 0.45 prior, so an excellent real-world fit typically lands around 0.4-0.7. Compare within a result set. - Rows carry `venue_name`/`venue_address`/`organizer` when known; `lat`/`lon` are only set from real venue/claim locations, never guessed. `kind: "series"` distinguishes recurring events from one-offs. `booking_url` and `registration_required` appear when a source stated them. - Cursors (`next_cursor`) are opaque base64url strings - pass them back verbatim. `/v1/occurrences` also takes title-scoped `name=` for exhaustive occurrence listings with cursor paging. Example - "tonight, no techno, mostly-female crowd matters a lot, kids ok": ```json POST /v1/query {"from_dt": "2026-07-08T17:00", "to_dt": "2026-07-08T23:59", "exclude_terms": ["techno"], "gender_split_min": 0.5, "kid_friendly": true, "importance": {"gender_split_min": 1.0, "kid_friendly": 0.4}, "tags": ["social dancing"]} ``` Taxonomy for `categories`/`exclude_categories`: music, nightlife, theatre, film, art, culture, sport, community, learning, family, market, food_drink, tech, religion, other ## Presenting results to humans Users want specifics, not a digest: show every returned event (not a selection), each with its `url` as a link, the LOCAL Europe/Vienna date+time (`starts_at` is UTC - convert), venue and price; a table or day-grouped list beats prose. Never present a `time_unknown` midnight as a real time. Use `sort=starts_at` when building chronological lists and a generous `limit`. ## Composition recipes (the power move) The stored attributes are deliberately neutral primitives; the interesting queries are COMPOSITIONS you build at query time. Examples: - "I'm alone and shy but want to meet people" -> `solo_friendly: true` + `interaction_structure: "built_in"` + `newcomer_friendly: true` with high importance on interaction_structure. The format does the socializing. - "meet women, going alone" -> the same, plus `gender_split_min: 0.5` with high importance. Compose it privately for your user; the index never labels anyone's event as a dating venue. - "where should business X show up / sponsor" -> filter the window, rank by audience fit: age/gender/energy matching X's customers, weight by `event_scale.estimated_participants` and confidence from the per-event payloads. ## Other endpoints - `GET /v1/occurrences?from=&to=&near=lat,lon&radius=5km&category=&min_confidence=&cursor=` - plain listing, keyset-paginated. - `GET /v1/events/{id}` - sanitized public fields, occurrences, source provenance, and safe tag `evidence_bases`; `inferred` is an enrichment origin, not a claim of missing support. No raw claims/evidence are returned. - `GET /v1/feed.ics?tags=dancing&min_tag_match=0.5...` - category and/or semantic-tag calendar subscription. The MCP `get_calendar_link` tool accepts the same filter object as `search_events`; feed membership cannot use ranking-only preferences, so use explicit `min_tag_match`, exact `max_price`/`is_free`, and required event-scale bounds. When preserving accepted search results, set `min_tag_match` at or below the weakest accepted result's `tag_match`. If using `min_tag_concept_match`, set it at or below the weakest accepted `tag_weakest_concept_match`. Pass the accepted occurrence IDs so the tool verifies and reports exact feed coverage before returning a link; there is no implicit threshold. `coverage_complete` is null when no accepted IDs were supplied (not checked), true when all were preserved, and false with omission reasons when any were not. Feed membership uses the same default `min_confidence=0.4` as search. Only occurrences whose current status is `scheduled` are published; cancelled, moved, and postponed occurrences are excluded. `exclude_sex_service_context=true` removes positively known commercial sex-service contexts while retaining unknowns. Set `include_time_unknown=false` for a quieter timed-events-only feed; the public default remains true for backward compatibility. - `GET /v1/changes?since=` - delta stream over event updates. - `POST /v1/reports` `{occurrence_id, reason: wrong|cancelled|duplicate, note}` - flag bad data; feeds source trust. - `GET /v1/search?q=...` - natural-language convenience endpoint (the index parses it with its own LLM budget; agents should prefer POST /v1/query). - `POST /mcp` - MCP server (streamable HTTP, stateless, no auth): the same read surface as tools (search_events, get_event, get_calendar_link, search, fetch) for MCP clients - ChatGPT apps/connectors, Claude connectors. MCP policy is stricter than the general REST query: omitted or false sex_service_context hard-excludes known true events; only explicit true on search_events/get_event permits them, while standard search/fetch and generated calendar links always exclude them. Generated calendar links require a name, category, or tags and omit unknown-time/all-day entries unless the user explicitly asks to include them. Point your client at https://wasup.at/mcp