Inspiration

Ask any chatbot how to market a café and you get the same placeless advice: a signature latte, post more reels, run a loyalty card. It would say the same thing about a café in Riyadh, Dubai or Portland, because it has no idea who walks past that particular door.

The hard part of marketing a small venue was never the copywriting — it is knowing the audience. That is a data problem, and it is exactly the problem Qloo's taste graph solves. So I built the thing that sits between the two: an agent whose recommendations are not allowed to exist unless Qloo data backs them.

What it does

You give Hayy a venue and a neighborhood. It returns:

  • who the people around that venue are and what they are into,
  • three menu moves,
  • a six-artist in-store playlist,
  • three collaboration partners,
  • five ready-to-post social posts, in Saudi-dialect Arabic and English,
  • and an evidence list mapping each claim back to the Qloo entity and affinity it came from.

Two things make it more than a prompt:

It shows its work. The page runs the same brief twice, side by side — once with a plain LLM and no tools, once with Hayy — and then prints the full Qloo call trace: every tool call, its arguments, the HTTP status, how many entities came back. You watch the recommendations get earned instead of asserted.

It is allowed to fail. If Qloo returns no taste data for a venue, Hayy refuses to write a brief rather than quietly falling back on the model's own priors. A grounded product that cannot say "I don't know" is not grounded.

How I built it

One Cloudflare Worker, one static HTML page. No build step, no dependencies, no framework.

The Worker exposes three tools to the model and lets it plan its own path through them:

tool Qloo endpoint purpose
qloo_search /search resolve the venue to an entity id
qloo_insights /v2/insights the core call: signal with entity ids, get affinity-ranked artists, brands, films, places
qloo_tags /v2/tags resolve cuisines and genres to tag ids

The key move is using the venue's own entity id as the taste signal (signal.interests.entities), so what comes back is what this audience likes rather than the city's generic top ten. Tool results are trimmed to names, ids, affinity and a few tags before they reach the model, which keeps a nine-call loop inside a sane context.

Challenges I ran into

Qloo's quiet failure mode cost me the most time. Passing location together with a non-place filter_type — artist, brand, movie — returns HTTP 200 with zero results rather than an error. My agent could not tell "nobody likes this" from "you asked wrong", so it retried variations and burned its budget. Riyadh looked great and Dubai looked broken, and for a while I believed that was a data-coverage gap in Qloo rather than a bug in my own querying.

Reading the call traces of the failed runs is what found it. The rule — location only with urn:entity:place, signal taste with interest_entities instead — is now in the tool description and the system prompt. Measured on identical briefs:

city before after
Downtown Dubai 9 calls, 4 with data, 141s 5 calls, 5 with data, 47s
San Francisco 8 calls, 5 with data, 151s 3 calls, 3 with data, 49s

Empty-result calls went to zero in every city.

Two smaller ones, both only visible once it was really running: models sometimes return the final brief as a tool call named "JSON", which the gateway rejects before my code ever sees it — so the loop now retries that step with no tools, which forces the answer into content. And a free LLM tier limits requests per minute, so the page asks for the two briefs sequentially; firing them in parallel tripped the quota on the very first click.

A note on the hosted demo

Every free LLM tier runs out — Cloudflare Workers AI caps a day at 10,000 neurons, Gemini's free tier at 20 requests — and one brief spends about eight calls. Rather than let the demo answer a judge with a stack trace, it falls through a list of OpenAI-compatible providers, and if they are all spent it serves a real saved run from this same code, labelled on screen as a saved run. So you may occasionally see that banner instead of a live call; it is a deliberate floor, not a failure, and it never passes saved output off as live. There is also a cap of 6 briefs per visitor per 10 minutes.

What I learned

A 200 is not a success. The most expensive bug here was an API answering cheerfully with nothing, and the only reason I caught it was that I had built the call trace into the UI for the judges' benefit — it ended up being the debugging tool. Making an agent show its work is not just a demo feature; it is how you find out what it is actually doing.

What's next

Arabic-first venue search, a weekly refresh so a café can watch its neighborhood's taste drift, and per-branch briefs for small chains.

Built With

  • ai-agents
  • arabic
  • cloudflare-workers
  • javascript
  • llm
  • marketing
  • qloo
  • saudi-arabia
  • taste-graph
  • workers-ai
Share this project:

Updates

Submission history