## Inspiration

Ask any recommender where to go tonight and it hands you a ranked list of restaurants. But a night out is not one place. It is a sequence — where you start, where you eat, where you end up — and the thing that makes it work is the order and the walk between them.

The other half of it was a complaint. Recommenders answer "what is popular near me", which in Abuja means a mall and a Domino's. We wanted the question to be "what matches the evening I am describing", and that is a taste question, which is what Qloo is for.

## What it does

TasteRoute turns a mood, a city and a pair of hours into a plan of three to five stops, in walking order, with the reason each one is there.

The hours decide the shape. A window starting at ten in the morning gets breakfast, something to do, the main meal and an afternoon. One running past one in the morning earns a club at the end. You type something like "low-key but not dead, somewhere with live music", pick a city, and an agent works out what that means in Qloo's taste graph — which tags to resolve, which to search on, whether the results are good enough or need widening — and the app assembles the route from what comes back.

Every stop shows why it is there, and every reason traces to a Qloo field: the tags that matched, the affinity score, what the place is good for, and Qloo's own explainability when the search was seeded with a place you already like. If Qloo has little to work with, the app says so and shows the other options instead of bluffing.

Times are estimated from your hours and the walks between stops, and the page says so. Nothing is booked.

Remove Qloo and there is no product left.

  • The mood becomes real Qloo tag URNs via /v2/tags. There is no hardcoded mood table in the codebase; the agent discovers the vocabulary each time.
  • Candidates come from /v2/insights with those tags as taste signals, scoped to the city's locality, and are ranked partly on Qloo's affinity.
  • Reasons are assembled from Qloo's fields, not written by a language model. A place with no Qloo signal is labelled unexplained rather than given an invented rationale, and a test enforces that.
  • Seeding with signal.interests.entities unlocks Qloo's explainability block, which is the strongest reason the app can show: "Qloo links this to the place you named."

## How we built it

Next.js with TypeScript, Tailwind for a layout that works on a phone and on a desktop, the OpenAI SDK for the agent loop, Zod at the Qloo boundary, and Vitest for 476 unit tests that run with no network at all, plus 12 live tests that check our claims about Qloo against the hackathon host.

The agent's authority is bounded: it decides what to ask Qloo, and our code decides what the answer is. The agent has four tools and none of them accepts a stop selection. Scoring, slot assignment, walking order and reason text are pure functions with unit tests, so the same pool always produces the same route. The agent handles the part that genuinely needs judgment — interpreting "low-key but not dead" — and nothing else.

The planner trace is visible in the UI. Every Qloo call the agent made, in order, with timings. That is both the honesty mechanism and the proof the thing is really agentic.

Qloo sits behind a TasteProvider interface with a hand-written fixture implementation, which is what made the engine testable and lets the deployed app fall back to clearly labelled sample routes rather than erroring when a key is missing.

## Challenges we ran into

The model does not always do the job. In live runs it sometimes resolved tags repeatedly and never searched; sometimes it searched only its mood tags and came back with nothing for one part of the evening. A plan needs a place for every part of it, so the loop nudges, rescues and backfills rather than handing the user an error.

Share links were silently broken. A live three-stop plan encoded to 248,060 characters, because the payload carried every scored alternative. Servers refuse URLs that long, so "Copy share link" had been producing links that looked fine and did not work. Stripping the plan down to what a reader actually needs took it to around 8,000.

Distance was a scoring input and not a constraint. An Abuja plan came back needing 131 minutes of walking and called itself a strong match. Charging for distance past the walkable range took the same query to 29 minutes.

Qloo's default answer for a city is its most famous places. Abuja comes back at popularity 0.984 and above, which is how our early routes became a mall and a Domino's. The fix became the highest-value control in the product: a famous-to-hidden-gems slider. Ask for hidden gems and you get a set with zero overlap. That one parameter is the difference between a tourist-trap generator and the product.

## Accomplishments that we're proud of

Requirements are not preferences. This is the design decision the whole project turns on.

A dietary need, an accessibility need, a budget ceiling and an area limit are requirements. They never reach the model. They become hard Qloo filters applied by our own code, so there is nothing for a better taste match to trade them away against. Somebody who needs a wheelchair-accessible entrance and gets a venue without one has been failed, however good the cocktails are.

Vibe, occasion and who you are going with are preferences. Those become weighted signals the agent resolves by querying Qloo's tag vocabulary at runtime. When they cannot all hold, TasteRoute gives up one at a time — area first, a stated dietary need last — and tells you exactly what it dropped.

We are also proud of what the app refuses to say. It reports a price band only where Qloo publishes one, and says how many of the stops that covers. It marks a stop open or shut only against the hours Qloo publishes. It offers no step-free filter, because Qloo has no step-free tag. Where a reason cannot be traced to a Qloo field, the stop is labelled unexplained rather than given an invented rationale.

## What we learned

Each of these cost a debugging round and none is in the docs:

  • filter.tags is a union, not an intersection. Asking for halal AND a wheelchair entrance returns places with either or with neither — a supermarket and a curtain shop. operator.filter.tags=intersection is the fix, and finding it late is why the first live test in our repo is the one that asserts it. Anybody building accessibility or dietary filtering on Qloo should check this before trusting their own screenshots.
  • feature.explainability only computes for entity signals. With tag signals it returns a warning and no explainability block.
  • Absent properties come back as explicit null, not omitted keys.
  • good_for is an array of tag objects; live_music is a boolean; hours is a weekday map of open/close time strings.
  • Genre URNs nest, so a cocktail bar is urn:tag:genre:place:restaurant:cocktail_bar. Match on the substring "restaurant" and every bar lands in your dinner slot.
  • Invalid parameters are silently ignored and still return 200. TasteRoute validates every outbound parameter name against an allowlist before building the request, because a typo would otherwise return a confident 200 for a query that did something else.
  • The age band 25_and_younger looks plausible and returns zero results. The real value is 24_and_younger.
  • There is no step-free accessibility tag — a query for "step free" returns parking and wifi — so the product deliberately does not offer one.
  • Price coverage is thin and worth measuring before you build on it. Over forty places each, Abuja returned a price level for 11, Lagos 4, Nairobi 2. price_range looks like the richer field until you read the populated values and find hotel nightly rates.

## What's next for TasteRoute

Transit and ride estimates between stops, so a plan can cross a city instead of staying inside a walk. Opening-hours coverage beyond what Qloo publishes. And group planning: one link, several people's taste signals, one route that holds for all of them.

Built With

Share this project:

Updates

Submission history