The memory model
Agent memory in Korely is a three-layer, EU-hosted knowledge
store. The layer that sets it apart is the bottom one: a durable
set of typed (subject, predicate, object) facts that
carry validity over time, supersede each other through a two-stage
contradiction check, and can be replayed at any past instant with
as_of. On top of those facts sits a managed cloud store of
memories (notes, transcripts, work-session logs) with semantic vector
recall, and a session-scoped view for thread-local working state. You
write plain text; extraction, embedding, entity linking, and
contradiction detection all happen automatically behind a single
add() call. Your agent's primary recall path,
get_context(), packs the current facts and the memories
most relevant to the query into a prompt-ready block, structured, current
knowledge on demand, with no generative model on the read path. For an
agent builder this means: never prompt-stuff stale context, never
manually reconcile conflicting facts, and never build a separate memory
layer from scratch.
Korely stores memory in three layers. One stack behind the REST API, the SDKs, the CLI and the MCP server: every agent that writes and queries it, and every end user who reads, corrects or forgets what an agent remembers, works on the same store. The memory store is the base, and two layers sit on top of it.
flowchart LR
A["Layer 1, Memory store<br/>add / search / get_context<br/>scoped by user_id"]
B["Layer 2, Session<br/>run_id tag<br/>thread-local scope"]
C["Layer 3, Facts<br/>typed triples<br/>valid_from · invalid_at · as_of"]
A -->|"mined at write time"| C
A -->|"run_id scopes a slice"| B
B -->|"facts extracted like any memory"| C
| Layer | What it holds | Scope | Lifetime |
|---|---|---|---|
| 1, Memory store | Notes, transcripts, work-session logs (kind note or session) | User | Forever (it is the user's data) |
| 2, Session memory | Memories tagged with one run_id | Session (a filter on Layer 1) | Until deleted, like any memory |
| 3, Cross-session facts | Typed (subject, predicate, object) triples | User | Bi-temporal: superseded, never silently lost |
Layer 1, The memory store
Layer 1 is a managed cloud store in the EU, Postgres with a vector
index. Every memory your agent writes lands here, scoped to your
account and an end-user namespace. You read and write it over the REST
API or the SDK; there is nothing to host yourself. You can read back
everything stored for a user, page by page (their memories with
get_all, their facts with
get_facts(include_invalidated=True), which also returns the
facts that ended), and erase
it all with one call, delete_all.
For agents, recall over the memory store is semantic vector search:
- 768-dimension vectors. Every memory is embedded as a
768-dimension vector, so
search()finds semantically similar content by cosine distance even when no keyword overlaps. The memory's embedding is computed once at write time; the query is embedded at read time. - Entity graph (behind the facts). The typed facts extracted from every memory form a graph: their subjects (typed as people, organizations, products, places, concepts or events) and their objects are the nodes, linked by predicates in 10 families. The graph is what powers the typed facts in Layer 3, it is not a search mode the agent queries directly. Details in The graph.
Your primary recall path is get_context() /
GET /v1/context. It embeds the query, picks the current typed
facts most relevant to it (up to 10, ranked by similarity and then
recency) and the most relevant memories, and packs them into one
prompt-ready block within a token budget. To list the facts true now
about one end user, call get_profile() (up to 200 facts) or
get_facts(), which pages through all of them with
limit and offset.
Raw search() /
POST /v1/memories/search is the secondary path when you want
the underlying memory snippets ranked by semantic similarity.
Layer 2, Session memory
Session memory is per-conversation working state. When an agent runs a
long thread, a research session, a multi-step refactor, a week-long
project, it calls add() with a stable run_id
to tag memories to that session. A later search() or
get_all() with the same run_id returns only the
memories tagged with it. The tag filters those reads and nothing else:
search() without run_id and
get_context() include these memories, and the facts
extracted from them join the end user's facts like those of any other
memory.
This is what keeps a thread coherent across context-window resets and client restarts: the agent re-reads its own session memories, picks up the state, and continues.
Layer 3, Cross-session facts
Layer 3 is what most people mean by "agent memory": things the system
knows about the user and their world, independent of any one note or
conversation. We store them as typed
(subject, predicate, object) triples, extracted
automatically whenever content is written, and queryable via
get_facts() in the SDK or GET /v1/facts in
the REST API.
Every predicate belongs to one of 10 families:
| Family | Example triple |
|---|---|
| preferences | (Marco, likes, peach fruit salad) |
| people | (Sara, reports_to, Marco) |
| places | (Marco, lives_in, Bologna) |
| work | (Sara, works_at, Acme GmbH) |
| ownership | (Marco, owns, a 2019 MacBook Pro) |
| health | (Marco, allergic_to, peanuts) |
| financial | (Aurora plan, costs, 50 euro per month) |
| events | (team offsite, scheduled_for, 2026-07-12) |
| identity | (Gordon, renamed_to, Korely) |
| other | catch-all for valid triples outside the 9 above |
Facts are bi-temporal
Every fact carries valid_from and invalid_at.
When new information contradicts an existing fact, a two-stage
contradiction check invalidates the old one and keeps it as history
instead of deleting it. A chain after a price change looks like this:
(Aurora plan, costs, 40 euro per month) valid_from 2026-05-18 invalid_at 2026-06-07(Aurora plan, costs, 50 euro per month) valid_from 2026-06-07 invalid_at null
By default, GET /v1/facts returns only the facts that are
true now: valid_from not in the future, and invalid_at null or still in the future. Pass
include_invalidated=true and superseded facts come back too,
each carrying its invalid_at timestamp (and, when the fact was
replaced through correct_fact, the invalidated_by
id of the replacement), so an agent
can reason about how a value changed over time, not just what it is now.
The full mechanics are in
Temporal facts.
Point-in-time queries: the REST API exposes the same
data with as-of slicing (GET /v1/facts?as_of=2026-05-15)
and a prompt-ready GET /v1/context block. See the
API reference.
The human is in the loop
Layer 3 is not a black box the agent maintains behind the user's back.
Every fact is a readable triple: get_profile lists them for
one end user, correct_fact fixes a wrong one,
forget_fact closes one, and delete_all erases a
user with an audit record. The dashboard has the same Correct and Forget
actions. Forgotten facts are excluded from agent reads by default. We
think memory an agent collects about a person should be inspectable by
that person, so your product can show it to them. More in
Human in the loop.
How a write flows
When an agent calls add() (or POST /v1/memories),
the pipeline runs like this:
- The memory is stored and embedded (one 768-dimension vector), so search can find it as soon as the call returns; if the embedder gives no vector, a background worker adds it later. The rest runs in the background.
- Typed fact extraction pulls (subject, predicate, object) triples into Layer 3 and, in the same call, writes a one-sentence summary of what the memory says about the end user; their subjects and objects are the nodes of the graph.
- Each new fact passes the two-stage contradiction check; contradicted facts get
invalid_atstamped.
The write call itself returns once the memory is persisted, with
facts empty on the immediate response; extraction then runs
behind it and the typed facts populate shortly after. You never block on
extraction. The write path is where the intelligence runs: the memory's
embedding, typed-fact extraction with
contradiction checking and bi-temporal validity. It costs a fraction
of a cent per memory, all included in your plan.
Here is the same pipeline from the SDK. One add call with
raw conversation text in, structured memory out:
from korely_memory import Korely
korely = Korely(api_key="kor_live_...")
# One write call. Extraction runs asynchronously behind it.
memory = korely.add(
"Giulia upgraded to the Advanced plan and asked us to stop "
"calling her. She prefers email follow-ups. She is based in Turin.",
user_id="customer-giulia-4812",
agent_id="support-bot",
)
print(memory.id)
mem_8f2c1a
# id shortened here; a real id is mem_ plus 32 hex characters Seconds later, the same raw sentence exists as a searchable memory and three typed facts, whose subjects and objects are nodes of the graph. One of the facts retires an older preference via the contradiction check (the families are the model's classification):
Memory mem_8f2c1a (embedded for vector search)
Facts (Giulia, has_plan, Advanced) family: financial (Giulia, likes, email follow-ups) family: preferences ↳ invalidated (Giulia, likes, phone calls) 2026-04-02 → 2026-06-11 (Giulia, lives_in, Turin) family: placesHow a read flows
Reads are retrieval, not generation. Most read calls,
get_facts(), get_profile(),
users(), history() in the SDK, or their REST
equivalents, are pure SQL lookups, zero AI calls.
get_context() embeds the query once, then picks the current
typed facts and the stored memories closest to it.
search() embeds the query once, a fraction of a hundredth of a
cent. Recall over the memory store is
semantic vector similarity (cosine); facts and profile reads are
deterministic SQL.
No generative model runs on the read path: no reranker LLM and no
answer synthesis. Your agent's own model is the only LLM that reads at
query time. In the get_context() block, the first five
memories appear in full; the rest appear as the one-sentence summary
that extraction wrote about the end user when the memory was stored,
when it wrote one. That split is why read quotas are at least a hundred
times more generous than write quotas; a read costs us a database query, not
an inference call. Facts reads are deterministic, with no model call;
search and get_context add one embedding call for the query.
The intelligence runs at write time. Fact extraction and contradiction checks need a model; retrieval does not. The split is intentional: writes are rare and asynchronous, reads are frequent and latency-sensitive. Pay the inference cost once on the way in, never on the way out.
Scoping a read: user_id, agent_id
Reads accept scoping parameters. Filters are additive (AND): omit
user_id to read across every end user in the project (add
agent_id to narrow it to one agent), and pass
run_id to search() or get_all() to
limit results to one session. Without run_id,
search() covers every session.
| Parameter | Scopes to | Example |
|---|---|---|
user_id | The end user your agent serves. End users are unlimited on every tier. | customer-giulia-4812 |
agent_id | Your application's namespace. Pass it to narrow a read to one agent. Agents of the same end user share that user's facts, and the wall between customers is the project (each API key belongs to one project). | support-bot |
run_id | One session or agent run: a tag on writes, and a filter on search() and get_all(). | 2026-06-11-session-03 |
# Same client as above. Scope the read to one person and one app.
results = korely.search(
"contact preferences",
user_id="customer-giulia-4812", # one end user
agent_id="support-bot", # your application
)
for r in results:
print(r.score, r.snippet)
0.84 Giulia upgraded to the Advanced plan and asked us to stop calling her. She prefers email follow-ups. She is based in Turin. Python and Node.js today. See pricing for plan details.
Which call touches which layer
The REST API and SDK give you one surface for all three layers. Which layer each call touches:
| SDK method / REST endpoint | Layer | Direction |
|---|---|---|
get_context() / GET /v1/context | 1 + 3, The current facts and memories most relevant to the query (primary recall) | Read |
search() / POST /v1/memories/search | 1, Memory store (semantic vector) | Read |
get_all() / GET /v1/memories | 1, Memory store | Read |
add() / POST /v1/memories | 1, Memory store (feeds Layer 3 extraction) | Write |
update() / PATCH /v1/memories/{id}, delete() / DELETE /v1/memories/{id} | 1, Memory store | Write |
add(run_id=...) / GET /v1/memories?run_id=... | 2, Session memory (scoped view of Layer 1) | Read / Write |
get_facts() / GET /v1/facts | 3, Cross-session facts | Read |
add_fact_triple() / POST /v1/facts | 3, Cross-session facts | Write |
Note the asymmetry: facts are extracted automatically from every
add() call, the memory is always the traceable source.
For explicit fact writes the SDK exposes add_fact_triple()
and the REST API exposes POST /v1/facts. See the
API reference for the full
contract.
EU-hosted: memories, facts, their vectors and the
graph are stored on our own infrastructure in the EU, on every tier.
To extract facts, the text of each memory goes to the language model of
the project's region:
Google's Gemini API by default (global), or gpt-oss-120b on
Scaleway in Paris (eu). See
Security.
End-to-end example: support bot with persistent memory
The following example shows all three layers interacting in a single support session. The agent writes a conversation turn, then reads back structured context for the next turn, including the typed facts that were extracted automatically from earlier sessions.
from korely_memory import Korely
korely = Korely(api_key="kor_live_...")
# ── Layer 1 write: the agent stores a conversation turn.
# Extraction runs async: typed facts are mined behind the call.
korely.add(
"Customer-4812 called about her Developer subscription. "
"She is based in Milan and wants all follow-ups by email. "
"She is allergic to peanuts (mentioned it while chatting).",
user_id="customer-4812",
agent_id="support-bot",
run_id="session-2026-06-16", # Layer 2 tag: scopes this turn to the session
)
# ── Layers 1 + 3 read: fetch a prompt-ready context block for the next turn.
# get_context() packs the Layer 3 facts and Layer 1 memories most relevant
# to the query into one block. No generative call on the read path.
ctx = korely.get_context(
query="preferred contact channel and account tier",
user_id="customer-4812",
agent_id="support-bot",
)
print(ctx.context)
# ── Layer 3 read: pull structured facts for an explicit check.
# Returns deterministic SQL rows, not a generated summary.
facts = korely.get_facts(
user_id="customer-4812",
agent_id="support-bot",
)
for f in facts:
print(f.subject, "|", f.predicate, "|", f.object, "|", f.predicate_family)
# Example output (f.predicate is normalized; f.predicate_raw keeps the source verb;
# f.predicate_family is the model's classification):
customer-4812 | lives_in | Milan | places
customer-4812 | likes | email follow-ups| preferences
customer-4812 | has_plan | Developer | financial
customer-4812 | allergic_to | peanuts | health
The agent wrote one short note, three sentences in a single call. Once the memory's status is
ready (see GET /v1/events), its typed facts
are in Layer 3; with this text you can expect rows like the ones above, and subjects and objects such as "customer-4812", "Milan" and "Developer" become nodes
of the graph. If an earlier contact preference contradicts the new one,
the contradiction check closes it. get_context() puts the facts relevant
to the query into the prompt block. No
post-processing, no prompt engineering to remember what was said three
turns ago.
SDK parity: the Node.js SDK exposes the same surface
with camelCase method names (getContext, getFacts,
addFactTriple, deleteAll, batchStatus).
The REST API is at https://api.korely.ai/v1 with
Authorization: Bearer kor_live_.... All plans, including
hobby (free), share the same three-layer stack.
See also
- Temporal facts,
how
valid_from,invalid_at, and the two-stage contradiction check work in detail, with point-in-timeas_ofqueries. - The knowledge graph,
the entities and typed edges the facts form, and how shared entities connect
memories your agent did not explicitly link. Query the neighborhood of
an entity with
GET /v1/facts?entity=.... - Human in the loop, how to let end users see, correct, and forget the memory their agents collect about them.