Over a few weeks this fall, one Agora shopper searched for a 10-foot Christmas tree. Then Alo yoga pants, nine times. Basketball shoes. Toddler Nike shoes. A toy for an 18-month-old, and a high chair to go with it. An ultrawide monitor. A hiking water bottle. A Shaq jersey. Yellow Converse, more than once. And something for a wedding, under $100.
Who is this person? It happens to be our founder & CEO, Param Jaggi. But for the sake of argument, let's act like we don't know him.
You probably have a guess. Maybe a parent with a young child, getting ready for the holidays. Maybe someone with a partner who does yoga. Maybe a basketball fan, or someone buying for one. Any of these could be true. Any could be wrong. The searches alone can't tell you, and neither can we.
That uncertainty is the whole problem of personalization in shopping. Every one of those searches is a real signal. Most of them, though, are signals about someone other than the person typing, or about a moment that has already passed. If you treat them all as facts about the shopper, you build a confident picture of someone who doesn't exist. Then you use it to change their search results.
This post is about how Agora tries to avoid that. It follows this one shopper's history through our system. It shows what a naive profile concluded, what our current system concluded, and why we think knowing less is the right answer for now.
Why shopping is different
Start with how ordinary web search works.
If you search "how long to boil an egg," the right answer is about the same for you as for anyone else. A search engine can personalize a little. It can use your language, your location, the sites you tend to click. But the query carries most of the meaning. The answer doesn't depend much on who is asking.
Shopping queries are the opposite. "Running shoes" leaves out almost everything that decides which pair is right: size, budget, terrain, how often you run, which brands have fit you before. A good salesperson fills those gaps by asking questions, or just by looking at you. A search engine can't look at you. It can only remember what you told it before.
So memory matters more in shopping than in most kinds of search. The trouble is that the most important fact about a shopping query is usually missing: who the product is for.
When someone types "running shoes," they could be shopping for:
Themselves, now. The simple case. It's also the one most recommendation systems quietly assume.
Themselves, later. Hiking boots for a trip next spring. A winter coat ahead of a move to a colder city. These describe who the shopper plans to be, not who they are today.
Someone else. A partner, a parent, a child, a friend's birthday. Their sizes, tastes and budgets have nothing to do with the shopper's own.
A household. Shower mats, coffee pods, a standing desk. Shared things, bought by one person for several.
Nobody yet. Just browsing, comparing, curious about what something costs. Interest is not intent.
Web search rarely needs to tell these apart. Shopping almost always does. And shoppers almost never say which case applies. Nobody types "running shoes, for me, my usual size, my usual budget." They type "running shoes."
A few more things make it harder:
Buying ends interest. Once you buy a mattress, you are the least likely person in the world to want another one soon. A system that treats past searches as lasting interests keeps showing you what you already bought.
Constraints are local. A $100 cap on something for a wedding says nothing about what you spend on yourself. A color you wanted for one pair of sneakers says nothing about your kitchen.
Evidence is thin. People shop any given category only a few times a year. Our shopper ran 134 searches over 96 sessions. That's a lot of searching, and very little evidence about any one preference.
Seasons distort everything. In October, half the internet seems to have a deep, lasting interest in Christmas trees.
What a shopper profile is
Before following our shopper further, it helps to see what Agora actually stores.
Every signed-in shopper has a profile. It's a single JSON document in our database, updated as they search. Guests don't have one. The sample we'll walk through belongs to the shopper above, with their identifiers removed.
The document has a small header that records how much has been processed:
"stats": {
"sessionsProcessed": 96,
"queriesProcessed": 134,
"lastRunAt": "2026-10-26T22:00:02.836Z",
"model": "qwen3:latest"
}A session is one conversation with Agora's search. It might hold a single query like "standing desk," or a back-and-forth like "mesh shower mat," then "non slip shower mat," then "does this come in blue?" The model field names the language model that read those sessions. More on that below.
Below the header, the profile holds two very different ways of describing the same person. The first is the obvious approach, and the one we started with. The second is what we use now.
The first answer: count everything
The simple way to learn preferences is to count. Every time a shopper types a color, brand, size or product, add one to a tally. Record when you last saw it. The most frequent terms become the profile.
Our shopper's profile still carries those tallies. Each entry has the term, how many times it appeared, and when it was last seen. Here are a few:
"brandsLiked": [
{ "term": "alo", "count": 9, "lastSeenAt": "2026-10-18T20:46:28.693Z" },
{ "term": "nike", "count": 4, "lastSeenAt": "2026-10-21T17:10:32.168Z" },
{ "term": "converse", "count": 4, "lastSeenAt": "2026-10-19T00:27:12.658Z" },
{ "term": "lululemon", "count": 2, "lastSeenAt": "2026-10-05T18:30:39.279Z" }
],
"genders": [{ "term": "men", "count": 5, "lastSeenAt": "2026-10-18T19:19:34.240Z" }],
"audiences": [{ "term": "toddler", "count": 1, "lastSeenAt": "2026-10-21T17:10:32.168Z" }],
"sizes": [{ "term": "10 foot", "count": 1, "lastSeenAt": "2026-10-15T20:43:16.761Z" }],
"budgets": [{ "min": null, "max": 100, "at": "2026-10-15T09:59:05.304Z" }]Collapse the tallies into a summary and you get a tidy description of the shopper. This is an excerpt of the summary stored in the profile:
"shopper": {
"gender": "men",
"audience": "toddler",
"colors": ["yellow", "black", "red", "blue", "white"],
"brandsLiked": ["alo", "nike", "converse", "lululemon", "hoka"]
}It reads like a reasonable customer profile. A man with a toddler, who likes athletic brands and bright colors. Every value traces back to something the shopper really typed.
Now look at where each value came from. The timestamps let you match every tally to the search that produced it.
"gender": "men"was counted five times, most recently at the same millisecond as a search for basketball shoes. Searching for men's products says nothing about who will wear them. Basketball shoes are a common gift."nike"gets part of its count from "toddler Nike shoes." That is a brand the shopper picked for a child, not for themselves."alo"and"lululemon"came from yoga pants and workout shirts. Those may be for the shopper. They may also be for a partner.The only size on file is
"10 foot". It's the height of a Christmas tree.The only budget, $100, was saved at the same moment as the word "wedding." It applies to one purchase for one occasion.
Imagine search using this summary. A query for "jacket" might lean toward men's styles. A query for "headphones" might be quietly capped at $100. A query for "sneakers" might push Nike, a brand the shopper only ever chose for a toddler. None of these guesses has any real basis. Worse, the shopper would see odd results and have no way of knowing why.
The counting approach didn't fail because of bad data. It failed because it assumed every search was about the person typing. For this shopper, as for most, that just isn't how shopping works.
The second answer: keep only what you can defend
The newer part of the profile is called the ledger. It answers the same question with very different rules. Here is everything it currently knows about our shopper, after reading their 18 most recent sessions:
"ledger": {
"sessions": 18,
"facts": [
{ "key": "clothingSize", "value": "34", "sessions": 1, "stated": false }
],
"people": [
{ "person": "18 month old", "sessions": 1,
"facts": [{ "key": "household", "value": "18 month old", "stated": true }] }
],
"brands": { "liked": [], "avoided": [] }
}One possible clothing size, not yet confirmed. One young child the shopper buys for. No brands, no colors, no gender, no budget.
That can look like a failure. We think it's the honest result. The rest of this post explains how the ledger is built, and why it ends up knowing so little.
How the ledger is built
The process runs once a minute and has four steps. Only one of them uses a language model.
Step 1: Collect
Every minute, a scheduled job gathers each signed-in shopper's conversations that have new messages since the last run. A profile is usually up to date within a minute or two of a search. Guests are skipped. So are any searches a shopper has chosen to hide.
Step 2: Read
A language model running on our own servers reads one conversation at a time, from start to finish, like a short story. It doesn't reply to the shopper. Its only job is to fill in a form with three sections:
What they looked for. One entry per product, with only the details typed for that product: color, size, budget, brands asked for or avoided, what it has to fit, and who it's for.
Facts about the shopper. Lasting things like "owns an iPhone 17," "shoe size 11" or "vegan." Each one is marked either as stated outright or merely implied.
People they shop for. "husband, shoe size 11." "daughter, starting college."
Reading in order matters. "Under $50" on its own means nothing. Typed after "phone case," it's a budget for that phone case and nothing else. A message without a product in it, like "in blue" or "not Nike," always refines the product that came before. A correction such as "no, I meant a laptop stand, not a laptop bag" replaces the earlier product instead of adding a second one.
The most important field on every product is for. It starts as "unclear". It changes to "shopper" only when the shopper uses first-person words about the product: "my," "for me," "I wear," "I need." It changes to "someone_else" only when they name a relationship, a person or an age. Searching for something is not evidence that it's for you. "Serum for oily skin," "phone case" and "gift" all stay "unclear" unless the shopper says otherwise.
Here is how the model read two of our shopper's sessions. First, the toy and high chair:
{
"queries": ["toy for a 18 month old", "high chair for 18 month old"],
"intents": [{
"product": "toy",
"attributes": ["for 18 month old"],
"for": "someone_else",
"person": "18 month old"
}],
"people": [{
"person": "18 month old",
"facts": [{ "key": "household", "value": "18 month old", "confidence": "stated" }]
}]
}The shopper named an age, so the toy is for someone else. The child goes into the "people" section, and nothing about the toy becomes a fact about the shopper.
Second, a pair of jeans:
{
"queries": ["blue jeans size 34 men"],
"intents": [{
"product": "jeans",
"colors": ["blue"],
"sizes": ["34"],
"for": "unclear"
}],
"shopperFacts": [
{ "key": "clothingSize", "value": "34", "confidence": "implied" }
]
}This is the kind of caution we want. Size 34 men's jeans are probably for the shopper. But nothing in the query says so. They could easily be for a brother or a partner. So the size is saved as implied, and for stays "unclear".
Step 3: Check the model's work
Next, plain code checks every value the model wrote against the words the shopper actually typed. Anything that doesn't appear in the queries is thrown away.
This one rule removes a whole category of mistakes. The model can't invent a budget, a brand, a gender or a health condition. It can't decide a product was for the shopper unless they said "my" or "for me." Language models are good at understanding what people mean. They're also good at filling silence with plausible guesses. The check keeps the first and drops the second.
Step 4: Merge
Finally, all of a shopper's sessions are merged into the ledger using simple counting rules. No model is involved here.
A fact counts if the shopper stated it once, or if it was implied in two separate conversations.
Newer information replaces older. Anything not seen for a year drops out.
Facts about other people stay attached to that person. They never become the shopper's own.
A price, color or size typed for one product stays with that product. Yesterday's gift budget is never today's limit.
Health, religion and other sensitive topics are never remembered automatically. They're set aside for a future "should I remember this?" prompt, so the shopper decides.
This is why the ledger is so short. Size 34 sits there with "stated": false and "sessions": 1. It's a candidate, not a fact. If the shopper looks for size 34 trousers in another session, or types "I'm a 34," it gets promoted. Until then it has no effect.
The 18-month-old is different. The shopper stated the age directly, so the child counts as a real person they shop for. But the child's needs stay with the child. The next time this shopper searches for shoes, nothing about toddlers will leak in.
Here is how a fact earns its place. Picture a different shopper who types "case for my iPhone 17," then "magsafe compatible." The word "my" makes "owns an iPhone 17" a stated fact, which is enough on its own. Days later, in a separate conversation, they search "screen protector for iphone 17," which implies the same thing again. A week after that they type "wireless charger." Now there's solid ground for looking for chargers that work with an iPhone 17.
How memory is allowed to change search
When a signed-in shopper sends a message, Agora combines the ledger with what they've said earlier in the same conversation. The current conversation always wins. That combined memory then goes to two places, and each has a narrow job.
The search planner turns the shopper's message into a search of the product catalog. It may use a remembered fact only to fill a gap the shopper left. This is the instruction it receives:
Background only fills a slot the latest message leaves empty: a case with no device named goes with shopper.owns, a serum with no skin type with shopper.skinType, as requirements.mustHave or compatibility words. Never use it when the message names its own device, size, skin type or person, never for a product that is for someone else, and never to change the product type.
So "phone case" can become a case for the phone the shopper owns. "Phone case for my wife's Pixel" can't. Remembered brands are a hint, never a filter. Nothing is ever hidden from results because of something the shopper did in the past. Recent searches never change what gets searched.
The assistant's written reply may mention a remembered fact only when it changes the advice. "This one works with your iPhone 17" is fine. A list of everything Agora remembers about you is not. Being remembered can be helpful. Being reminded of it all the time feels like being watched.
The rule underneath both is the same. Memory fills gaps. It never overrides what the shopper just said, and it never narrows what they're allowed to see.
Where it still goes wrong
The reading model makes mistakes. Our shopper's profile has a few.
In one session, the shopper searched "yellow converse." The results weren't right. They wrote "most of these are not even yellow" twice. Then, with understandable frustration: "are you dumb, these are still not yellow."
The model correctly marked the search as one the shopper had to correct. But it also recorded yellow as a color to avoid, which is exactly backwards:
{
"product": "converse",
"colors": [],
"colorsAvoided": ["yellow"],
"corrected": true
}The words "not yellow" look like an exclusion. In context, they're a complaint about the results. The check in step 3 didn't catch this, because "yellow" really was typed. The check can confirm that a word came from the shopper. It can't confirm the model understood what the shopper meant by it. It's also a fair reminder that the frustration was earned. Our search failed first.
Other mistakes were smaller. The model read "thanks buddy" as a search for a product called "buddy." It saved "toddler nike shoes" as one product name instead of shoes, from Nike, for a toddler.
None of these mistakes changed what the shopper saw. A fact has to be stated, or implied twice, before it counts. Colors and brands stay with the product they were typed for. The planner treats brands as a hint. One bad reading does very little harm. That's deliberate. We expect the model to be wrong sometimes, so everything after it is built to assume so.
We still want the model to be right more often. Mistakes like these are the cases we use to test the next version.
What is live today
The ledger is being built today for every signed-in shopper. The part that uses it to change search results is switched off. We'll turn it on once we've measured that it actually improves outcomes, like clicks and saves, compared with search that has no memory at all.
We're holding it back on purpose. Wrong personalization is worse than none. A search that doesn't remember you is plain. A search that remembers you wrongly is confidently wrong, and it's hard to correct when you can't see why it's happening.
Back to our shopper
So who is the person who searched for a Christmas tree, yoga pants, basketball shoes and a high chair? We technically know it's Param Jaggi but it's more fun to act like we don't.
What we do know is small and solid. They shop for an 18-month-old. They may wear a size 34. Everything else is either about someone else, about one specific purchase, or not yet confirmed.
The goal is a system that eventually notices what a good salesperson would. The phone you own. The size you mentioned once. That you shop for your kids in August and for yourself in January. That you always want the lightest option, whatever the product. It should use those details quietly, and only where they help.
The hard part isn't collecting more signals. Shoppers give us plenty. The hard part is knowing which signals are about the person in front of us, which are about the people they care for, and which belong to a moment that has already passed. For now our answer is to be strict.
That makes Agora slower to learn about you than a system that guesses. We think that's the right tradeoff. Your results should reflect what you asked for, not a profile of someone you aren't.



