A chatbot that remembers every customer
Giulia opens your support chat for the third time this month. A stateless bot asks for her email again, asks what plan she is on again, and has no idea she already reported the same shipping problem twice. She types the whole story a third time.
With Korely in the loop, the same bot opens with: "Hi Giulia, I can see your Advanced plan and the open shipping issue from last week. I'll follow up by email as usual, unless you'd prefer otherwise." Same LLM, same prompt template. The difference is four API calls. This cookbook walks through all four at the function level. The words below (end user, memory, fact, agent_id) are defined in the Glossary.
The snippets below use the Python SDK (pip install korely-memory, version 0.1.28 was used to check
them) and a key from the quickstart.
The same four calls work over the
REST API and the
Node SDK
(npm install korely-memory).
The four calls
1. On chat open: load who she is
Before the first LLM turn, fetch the customer's active facts. This read
uses no generative model, so it adds a database query, not an inference
call, to your time-to-first-token budget. Always pass
user_id: a read without it covers every end user of the
project.
from korely_memory import Korely
korely = Korely(api_key="kor_live_...", region="eu")
facts = korely.get_facts(user_id="customer-giulia-4812")
The response is a flat list of her active facts: typed
(subject, predicate, object) triples that Korely extracts
from her memories. A fact is live until its invalid_at
(null while nothing has closed it); predicate is
the canonical relation (the relation as the extractor wrote it is kept in
predicate_raw; her own words stay in the memory that
source_memory_id points to), and each fact
carries a predicate_family for grouping. (Need them grouped
by family instead? Call get_profile.) The JSON below is
illustrative: the extractor is a model, so the predicates, the families
and the exact objects differ from one run to the next. An event such as
"reported a delay" can come back as a closed past fact, so do not rely
on a fact for the open shipping issue. Its memory stays searchable, and
step 2 reads it from there.
{ "facts": [ { "id": "fct_a1", "subject": "customer-giulia-4812", "predicate": "upgraded_to", "object": "Advanced plan", "predicate_family": "financial", "predicate_raw": "upgraded_to", "valid_from": "2026-05-02T00:00:00Z", "invalid_at": null, "invalidated_by": null, "source_memory_id": "mem_91a2" }, { "id": "fct_a3", "subject": "customer-giulia-4812", "predicate": "prefers", "object": "email follow-ups", "predicate_family": "preferences", "predicate_raw": "prefers", "valid_from": "2026-05-19T11:02:00Z", "invalid_at": null, "invalidated_by": null, "source_memory_id": "mem_5c0d" }, { "id": "fct_a4", "subject": "customer-giulia-4812", "predicate": "dislikes", "object": "phone calls", "predicate_family": "preferences", "predicate_raw": "dislikes", "valid_from": "2026-05-19T11:02:00Z", "invalid_at": null, "invalidated_by": null, "source_memory_id": "mem_5c0d" } ], "total": 3}Render that into your system prompt as a compact block and the bot greets her like it knows her, because it does.
Turn 0
Giulia opens the chat
- Third visit this month
- Your bot resolves her to user_id "customer-giulia-4812"
Korely
get_facts(user_id=...)
- Active typed facts only (true now)
- No generative model on the read
Turn 1
Personalized greeting
- "I can see your Advanced plan and the open shipping issue"
- Zero questions she already answered
2. During the chat: search her history
When she mentions the shipping problem, don't make the LLM guess. Search her memories, scoped to her and only her:
hits = korely.search( "shipping complaint", user_id="customer-giulia-4812", limit=5,)
What comes back is ranked retrieval, not generated text: a
semantic search scoped to her
user_id. No generative model runs on the read; the query is
embedded on Korely's own server. Your bot's own model does the reasoning
over the results. The SDK returns the list of hits directly (the REST body
wraps it in results, as below). Each hit is {id, score, snippet, user_id, agent_id,
metadata}, the snippet is the first 280
characters of the memory (read the whole text with get(id)
when it is longer):
{ "results": [ { "id": "mem_2b91d4", "score": 0.68, "snippet": "Order #88412 delayed at the Bologna hub, second report. Promised an email update within 48h.", "user_id": "customer-giulia-4812", "agent_id": "support-bot", "metadata": {} }, { "id": "mem_77c0ae", "score": 0.61, "snippet": "First shipping complaint for order #88412. Courier marked the address as incomplete.", "user_id": "customer-giulia-4812", "agent_id": "support-bot", "metadata": {} } ]}
Filters are additive (AND): user_id alone searches everything
stored about Giulia across every conversation; add run_id to
narrow to one session. The filter is applied by the server, not by your
prompt, so another customer's memories are not part of her results. The
score is a similarity, useful for ordering the hits of one query, not
for comparing different queries.
3. On new info: write it down
Back in May, Giulia mentioned she'd rather not be called on the phone. That is how the email preference in step 1 got there. Store it:
result = korely.add( "Prefers email follow-ups, not phone", user_id="customer-giulia-4812", agent_id="support-bot",)
The call returns the stored memory immediately. Fact extraction runs
after the write returns (usually within about ten seconds), so the reply
always has status processing and an empty
facts list:
{ "id": "mem_5c0d", "content": "Prefers email follow-ups, not phone", "user_id": "customer-giulia-4812", "agent_id": "support-bot", "run_id": null, "client": "korely-python/0.1.28", "kind": "note", "metadata": {}, "status": "processing", "created_at": "2026-05-19T11:02:00Z", "updated_at": "2026-05-19T11:02:00Z", "facts": []}
Read the facts with get_facts at the next chat open (or poll
GET /v1/events). This is the part where facts carry dates: if Giulia
previously preferred phone calls and a model judge decides the new
preference replaces it, the old fact is not deleted when the new one
lands. It gets an invalid_at timestamp and an
invalidated_by pointing at the new fact, stops being served
by the default get_facts, and survives for audit and
point-in-time queries (as_of). Ask for the closed facts too
and both show up. (The extractor picks the predicate name, so
prefers here is an example, not a guaranteed string. The
judge is a model and can miss: write the new state in the present tense,
for example "Giulia now prefers email, not phone", rather than telling the
change as a story in the past, and check with
include_invalidated=True when it matters.)
history = korely.get_facts( user_id="customer-giulia-4812", predicate="prefers", include_invalidated=True,){ "facts": [ { "id": "fct_a3", "subject": "customer-giulia-4812", "predicate": "prefers", "object": "email follow-ups", "predicate_family": "preferences", "predicate_raw": "prefers", "valid_from": "2026-05-19T11:02:00Z", "invalid_at": null, "invalidated_by": null, "source_memory_id": "mem_5c0d" }, { "id": "fct_b0", "subject": "customer-giulia-4812", "predicate": "prefers", "object": "phone calls", "predicate_family": "preferences", "predicate_raw": "prefers", "valid_from": "2026-03-08T15:30:00Z", "invalid_at": "2026-05-19T11:02:00Z", "invalidated_by": "fct_a3", "source_memory_id": "mem_14e2" } ], "total": 2}
The JSON above is illustrative. In a real run of the opposite change, a
second write said "Giulia now prefers phone calls in the morning, not
email." About ten seconds later the new
prefers: phone calls in the morning fact was open, and both
older facts (prefers: email follow-ups and
dislikes: phone calls) were closed, each with
invalidated_by pointing at the new one. A fact that is closed
this way keeps its dates, so a read with as_of before the
change still returns it.
4. On account change: let the contradiction engine work
You don't write supersede logic. When Giulia upgraded on 2 May and the bot
(or your billing webhook) wrote the new state, "Giulia is now on the
Advanced plan" (the write reply again says processing,
with an empty facts list), the contradiction detector (a
prefilter picks candidate facts, then a model judges whether the new fact
makes the old one false) can close the existing "subscribes to Basic
plan" fact and supersede it. The closure is a model's judgement, so
read with include_invalidated=True to see what was closed.
When it closes the old plan, the history read shows both (illustrative):
korely.get_facts( user_id="customer-giulia-4812", include_invalidated=True,)
The extractor chooses the predicate name, so it can vary from one write to
the next (here it is subscribes_to; a real run of the
upgrade sentence gave upgraded_to). Read all the
facts with include_invalidated=True and group them on your
side, or filter by predicate_family, rather than relying on an
exact predicate string.
{ "facts": [ { "id": "fct_a1", "subject": "customer-giulia-4812", "predicate": "subscribes_to", "object": "Advanced plan", "predicate_family": "other", "predicate_raw": "subscribes_to", "valid_from": "2026-05-02T09:14:00Z", "invalid_at": null, "invalidated_by": null, "source_memory_id": "mem_91a2" }, { "id": "fct_a0", "subject": "customer-giulia-4812", "predicate": "subscribes_to", "object": "Basic plan", "predicate_family": "other", "predicate_raw": "subscribes_to", "valid_from": "2026-02-10T08:00:00Z", "invalid_at": "2026-05-02T09:14:00Z", "invalidated_by": "fct_a1", "source_memory_id": "mem_08b3" } ], "total": 2}The write path is where the intelligence runs: embeddings, entity extraction, typed-fact extraction with contradiction checking and start and end dates for facts. All of it is included in the write: one write for every 6,000 characters of a memory, counted against your plan's monthly writes. Nobody edits anything by hand.
Scoping: one agent, unlimited customers
The scoping model is three strings you choose, and it maps one-to-one onto the scoping you already use elsewhere (see the migration guide):
| Parameter | What it identifies | Example |
|---|---|---|
user_id | Your end user. A string you choose: 1 to 255 characters, no '/' or control characters, and not '.' or '..'. | "customer-giulia-4812" |
agent_id | Your application's namespace. | "support-bot" |
run_id | One session or conversation. | "chat-2026-06-11-a" |
The part that matters for your bill: a support bot serving 10,000
customers is one agent_id with 10,000
user_id values, and end users are unlimited on every tier,
including the free one. What's metered is volume: writes (one for every
6,000 characters of a memory) and reads (searches, facts, context, profile) per
month (Hobby:
250/25k, Developer at
19 euro + VAT:
2k/250k, Team at
79 euro + VAT:
8k/1M). Reads are retrieval, not generation, which is why every
plan allows at least a hundred times more reads than writes. No
overage billing: a quota.warning webhook fires at 80% of the
monthly writes, and past a 10% grace you get a clean 429,
never a surprise invoice.
Why not just stuff the history into the prompt?
Prompt-stuffing is the default pattern, and it makes chatbots expensive and forgetful at the same time. Three concrete reasons:
- Token cost. Re-sending a 20-turn history is thousands
of tokens, every turn, forever, and it grows with every message. A
facts block from
get_factsis a few hundred tokens, fetched once at chat open, and it grows with what's worth remembering, not with conversation length. - Staleness. A raw history contains both "I'm on Basic"
(March) and "I upgraded to Advanced" (May), and you're trusting the LLM
to pick the right one every single time. When the judge closes the
superseded fact,
get_factsreturns only the facts that are valid now. - Forgetting. When Giulia says "forget my data", deleting her
from conversation logs scattered across your own database is a project.
With Korely it's one call,
DELETE /v1/users/customer-giulia-4812/memories, and every memory and fact stored for heruser_idin that project is physically deleted (a receipt keeps only counts and a pseudonym, and encrypted backups keep a copy for up to 35 days).
The trust angle
This deployment takes two shapes, and the data-ownership story differs:
- A "what you remember about me" screen: the end user
sees what the bot remembers, because your UI lists
get_profileand wires Correct and Forget tocorrect_factandforget_fact. Correcting what the bot knows, and closing what is no longer true, become product features the user controls, not a support ticket; a "delete my data" button callsdelete_all(user_id=...)for erasure. - Pure B2B2C (the Giulia scenario, she's your customer, not ours): you own the data, and the deletion surface is API-side. Your privacy policy, your deletion endpoint wiring, our one-call bulk delete underneath. Memories are stored in the EU (Helsinki) either way. Choose the Europe processing region for the project if the text must also be read by a model in the EU.
One honest caveat: the write path
(korely.add) runs the extraction pipeline asynchronously, so
newly extracted facts land shortly after the write returns (usually in
about ten seconds), not within the same request. Write fire-and-forget during the chat and rely on
get_facts at the next chat open. The read path
has no generative model, so it is the fast one.
Where to go next
Temporal facts explains supersede and point-in-time queries under the hood; memory model covers the full scoping model; the REST API reference is the published contract the snippets above come from.