A travel assistant that knows your taste
A user opens your travel assistant and types "find me somewhere to eat near the hotel tonight." A stateless assistant has to ask: window or aisle preferences aside, are you vegetarian? what's your budget? do you hate loud rooms? You've answered every one of those questions before, across a dozen sessions. Re-asking is the tax a memoryless agent charges on every turn.
With Korely in the loop, the assistant opens already knowing the user
prefers boutique hotels over chains, eats pescatarian, caps dinner around
€60, and can't do red-eye flights. It tailors the suggestion to what
they like, what they can't do, and what they dislike, without re-asking.
This cookbook walks the pattern at the call level, and the key call is
get_profile.
The snippets use the Python SDK (pip install korely-memory).
The same calls work over the
REST API and the
Node SDK.
The moat: the profile is the user's current taste
get_profile returns the assembled, active profile of one end
user, every fact that is true now, grouped by predicate family
(preferences, dietary, budget,
constraints). When the user says "I'm not vegetarian
anymore, I eat fish now," that new fact supersedes the old one: the
two-stage contradiction check invalidates "is vegetarian" and the
profile serves "is pescatarian" instead. You personalize from
resolved truth, not a raw pile of history where both the old and the new
preference sit side by side and the model has to reconcile them on every
turn.
The scoping: one end user, their whole taste
Each traveller is one user_id. Everything the assistant learns
about them, across web, app, and any channel, accumulates under that id,
and get_profile reads it back per end user. (agent_id
narrows to a specific agent's namespace if you run more than one.)
from korely_memory import Korely
korely = Korely(api_key="kor_live_...", region="eu")
USER = "traveller-4812" # one user_id per end user1. Before you suggest anything: load the profile
Read the traveller's current taste in one call. get_profile is
deterministic SQL retrieval, no model on the read path, and it returns the
active facts grouped by family, the end user's own facts first.
profile = korely.get_profile(user_id=USER)
print(profile.total) # 9print(list(profile.by_family)) # ["preferences", "dietary", "budget", "constraints"]
# Pull just the dietary facts straight from the grouped viewfor fact in profile.by_family["dietary"]: print(fact.subject, fact.predicate, fact.object) # traveller-4812 is_diet "pescatarian"{ "user_id": "traveller-4812", "as_of": null, "total": 9, "truncated": false, "by_family": { "preferences": [ { "id": "fact_8a1c", "subject": "traveller-4812", "predicate": "prefers", "object": "boutique hotels over chains", "predicate_family": "preferences", "subject_type": "person", "object_is_literal": true, "confidence": 0.93, "user_id": "traveller-4812", "agent_id": null, "valid_from": "2026-02-11", "invalid_at": null, "invalidated_by": null, "source_memory_id": "mem_7c1a", "created_at": "2026-02-11T09:14:02Z" }, { "id": "fact_4d20", "subject": "traveller-4812", "predicate": "dislikes", "object": "loud, crowded restaurants", "predicate_family": "preferences", "subject_type": "person", "object_is_literal": true, "confidence": 0.88, "user_id": "traveller-4812", "agent_id": null, "valid_from": "2026-03-02", "invalid_at": null, "invalidated_by": null, "source_memory_id": "mem_2b40", "created_at": "2026-03-02T18:41:55Z" } ], "dietary": [ { "id": "fact_9f08", "subject": "traveller-4812", "predicate": "is_diet", "object": "pescatarian", "predicate_family": "dietary", "subject_type": "person", "object_is_literal": true, "confidence": 0.95, "user_id": "traveller-4812", "agent_id": null, "valid_from": "2026-06-09", "invalid_at": null, "invalidated_by": null, "source_memory_id": "mem_c331", "created_at": "2026-06-09T20:03:11Z" } ], "budget": [ { "id": "fact_1e77", "subject": "traveller-4812", "predicate": "budget_cap", "object": "EUR 60 per dinner", "predicate_family": "budget", "subject_type": "person", "object_is_literal": true, "confidence": 0.91, "user_id": "traveller-4812", "agent_id": null, "valid_from": "2026-04-18", "invalid_at": null, "invalidated_by": null, "source_memory_id": "mem_5a92", "created_at": "2026-04-18T12:27:40Z" } ], "constraints": [ { "id": "fact_3b55", "subject": "traveller-4812", "predicate": "cannot_do", "object": "red-eye flights", "predicate_family": "constraints", "subject_type": "person", "object_is_literal": true, "confidence": 0.9, "user_id": "traveller-4812", "agent_id": null, "valid_from": "2026-01-30", "invalid_at": null, "invalidated_by": null, "source_memory_id": "mem_0d14", "created_at": "2026-01-30T07:55:09Z" } ] }}Drop these facts into your system prompt and the very first suggestion lands inside the traveller's taste: a quiet pescatarian-friendly spot near the hotel under €60. No questionnaire.
New turn
User asks for a dinner suggestion
- "Somewhere to eat near the hotel tonight"
- Resolves to user_id "traveller-4812"
Korely
get_profile(user_id=...)
- Active facts only, grouped by family
- Contradictions already resolved
Suggestion
Tailored on the first try
- Pescatarian, quiet, under EUR 60
- No re-asking the known preferences
2. When the user states a new preference: write it down
Mid-trip the traveller mentions "actually I love rooftop bars."
Capture it with add. Server-side extraction turns the sentence
into a typed (subject, predicate, object) triple under the
preferences family, so it shows up in the next
get_profile.
korely.add( "I love rooftop bars with a view — that's always a win for me.", user_id=USER,)# Returns immediately. The typed fact# (traveller-4812, likes, "rooftop bars with a view") lands shortly after.If the new preference contradicts an older one, say the traveller swore off rooftop bars last winter, you don't write any supersede logic. The contradiction detector finds the conflicting fact (same predicate family, opposing object) and invalidates it; the profile keeps serving only what's true now.
3. For a specific trip query: pull a context block instead
When the request is narrow, "plan two days in Lisbon", you don't
need the whole profile. get_context assembles a prompt-ready
block scoped to the query: the relevant slice of the profile plus the most
relevant memories, fitted to a token budget, in one call.
ctx = korely.get_context( query="two-day Lisbon trip: where to eat, where to stay, what to avoid", user_id=USER, token_budget=800,)
# ctx.context -> a Markdown block to drop into the system prompt# ctx.tokens -> how many tokens it used# ctx.sources -> the memory ids it drew from{ "context": "Known preferences:\n- prefers boutique hotels over chains\n- dislikes loud, crowded restaurants\n- is pescatarian\n- dinner budget: EUR 60\n- cannot do red-eye flights\n\nRelevant past trips:\n- Loved a quiet seafood place in Porto in March; asked to avoid touristy main squares...", "tokens": 287, "sources": ["mem_c331", "mem_2b40", "mem_5a92"]}The Lisbon plan now respects the budget, the diet, and the no-red-eye constraint without a single clarifying question, and it leans on what the traveller actually enjoyed on past trips.
Why not just keep the chat history in a vector DB?
- Stale preferences. A transcript store holds both "is
vegetarian" and "eats fish now", and you're trusting the LLM
to pick the right one each turn.
get_profilereturns only what's valid now, the contradiction is resolved before the model sees it. - No grouping. Personalization wants the budget facts here,
the dietary facts there.
by_familyhands you the profile already grouped by predicate family; a vector store hands you a ranked pile of snippets to sort yourself. - Token cost. Re-feeding a dozen sessions of chat is
thousands of tokens every turn. A profile or a
get_contextblock is a few hundred and grows with what's worth remembering, not with how long the user has been a customer.
Two honest caveats. get_profile is capped at
200 facts, when an end user has more, profile.truncated is
true and profile.total tells you the real count,
so reach for a query-scoped get_context rather than the full
profile for heavy users. And korely.add extracts facts
asynchronously: a stated preference becomes a typed fact a few seconds
after the write returns, ready by the next get_profile, not
inside the same call. Write preferences fire-and-forget during the
conversation; read the assembled profile at the start of the next turn.
Where to go next
Temporal facts explains how a new preference supersedes the old one under the hood; get context is the query-scoped call from step 3; the multi-session research cookbook applies the same profile-and-context pattern to a long-running research thread.