Migrate from Mem0
If you built on Mem0, the Korely API will feel familiar. The verbs are the
same: add a memory, search memories, list them, delete them. The scoping
vocabulary is the same: user_id identifies your end user,
agent_id identifies your application, run_id
identifies a session. Switching is mechanical: map the calls, replay the
corpus through the batch endpoint, verify with a search.
The short version: three steps. Export your memories
from Mem0 as JSON, import them with POST /v1/batch, then
run a search against the migrated corpus. Entity extraction, graph
edges, and typed facts are built automatically in the background after
the import, with nothing to enable and nothing to configure. Once
GET /v1/events reports processing: 0, they are
ready to read.
Terms
The Glossary defines the words used across the docs. This page uses memory, fact, end user, agent_id, run_id, metadata, batch, valid_from, extraction, events and context block as defined there.
The Mem0 calls on this page are the MemoryClient of the
mem0ai Python package, version 2.x (checked on 2.2.1).
Concept mapping
Mem0 and Korely share the same scoping vocabulary. The table below maps each Mem0 concept to its Korely equivalent so you know what to keep, what to rename, and what is new.
| Mem0 concept | Korely equivalent | Notes |
|---|---|---|
| Memory | Memory (mem_*) | Same unit. Stored text plus metadata, scoped by user/agent/run. |
user_id | user_id | Same meaning, a free-form string of 1 to 255 characters with no / or control characters and not . or .. (replace a / before importing). End users are unlimited on every plan. |
agent_id | agent_id | Same meaning and the same id rules as user_id. Namespaces your application. If you used Mem0's app_id for that, it maps here too. Count is plan-gated (hobby: 2, developer: 10, team: 100, scale: 500). |
run_id | run_id | Identical. Sub-scopes one session or conversation. Mem0's list endpoint returns it under the name session_id; its search returns run_id. |
metadata | metadata | Same shape. Arbitrary JSON object, stored and returned as given, at most 8,192 bytes once serialized (a larger one refuses the whole batch with a 422). |
| Graph memory | Entity graph + typed facts (fct_*) | Korely extracts entities and typed (subject, predicate, object) triples from every memory at write time. No separate enable flag. Available on all plans, free hobby tier included. Korely's facts carry bi-temporal validity (valid_from, invalid_at) on every plan. |
| Categories / custom categories | Predicate families | Korely groups predicates into canonical families (preferences, people, places, work, ownership, health, financial, events, identity) plus an other bucket for everything outside those groups. No custom categories; the graph self-organises from the text. |
| History | History + bi-temporal validity | Mem0's history returns old_memory and new_memory for each change. Korely's GET /v1/memories/{id}/history is the memory's timeline: created, its latest update, deleted, and every fact it produced, with when the store learned that fact and, if superseded, when the store closed it. It does not keep earlier texts of the memory (Mem0's old_memory); what the memory used to assert lives in its facts: get_facts(include_invalidated=True). Contradicted facts are invalidated, not deleted. |
Memory export (create_memory_export) | Not needed: list with get_all, import with a batch job (job_*) | Mem0's export returns data shaped by a schema you give it, not a copy of your memories, so step 1 uses the list call instead. Mem0 has no import job. POST /v1/batch accepts up to 500 memories per request, processed asynchronously. Poll with GET /v1/batch/{id}. |
| Organization | API key (kor_live_*) | An account holds projects and keys; each key writes into one project. Multiple agents live under the same key, each with its own agent_id. |
Call mapping
Every core Mem0 call has a Korely equivalent, in the Python SDK and over REST. The full REST contract is in the API reference.
| Mem0 call | Korely SDK | Korely REST |
|---|---|---|
m.add("...", user_id="u1") | korely.add("...", user_id="u1") | POST /v1/memories |
m.search("...", filters={"user_id": "u1"}) | korely.search("...", user_id="u1") | POST /v1/memories/search |
m.get_all(filters={"user_id": "u1"}) | korely.get_all(user_id="u1") | GET /v1/memories?user_id=u1 |
m.get(memory_id) | korely.get(memory_id) | GET /v1/memories/:id |
m.update(memory_id, text="...") | korely.update(memory_id, content="...") | PATCH /v1/memories/:id |
m.delete(memory_id) | korely.delete(memory_id) | DELETE /v1/memories/:id (forgets it: it drops out of reads, and the facts only it stated are closed and kept as history; to erase a person's data, delete_all) |
m.delete_all(user_id="u1") | korely.delete_all(user_id="u1") | DELETE /v1/users/:user_id/memories |
m.history(memory_id) | korely.history(memory_id) (timeline of events and facts, no old_memory/new_memory) | GET /v1/memories/:id/history |
The scoping parameters keep their names and meaning: Mem0 reads take them
inside filters, Korely takes them as plain arguments.
user_id is your end user's identifier, free-form,
and end users are unlimited on every tier. agent_id namespaces
your application. run_id sub-scopes one session. Filters are
additive.
In code, the switch looks like this:
# before
from mem0 import MemoryClient
m = MemoryClient(api_key="...")
m.add("Prefers invoices as PDF", user_id="customer-4812")
# after
from korely_memory import Korely
korely = Korely(api_key="kor_live_...")
korely.add("Prefers invoices as PDF", user_id="customer-4812")
results = korely.search("invoice preferences", user_id="customer-4812") Python and Node.js today.
Gotchas
The surface is familiar, but a handful of parameter names and behaviours differ. Check these before running your migration.
| Mem0 | Korely | What to do |
|---|---|---|
m.update(id, text="...") | korely.update(id, content="...") | Rename the keyword argument from text (or the older data) to content. Shape is otherwise identical. |
m.add(..., timestamp=...) | korely.add(..., timestamp=...) | Same name, different format: Mem0 takes a Unix epoch in seconds, Korely an ISO 8601 string. Convert it, e.g. datetime.fromtimestamp(ts, timezone.utc).isoformat(). add(), POST /v1/memories and every POST /v1/batch item take an optional timestamp (ISO 8601), stored as the facts' valid_from, so a backdated memory anchors temporal validity to the original event instead of the import day. |
categories / custom_categories | Predicate families (automatic) | Korely self-organises facts into canonical predicate families (with an other catch-all); there are no caller-defined categories. Filter facts with get_facts(predicate_family=...), or by entity when a predicate falls in other. |
output_format / version | Not applicable | Korely returns one stable JSON shape. If you pass them, drop them. |
Watch for 429 agent_cap_exceeded. Each plan caps the
number of distinct agent_id values (hobby: 2, developer: 10,
team: 100, scale: 500). End users under each agent are unlimited. If you
fan out a separate agent_id per customer, switch to a
per-user namespace with user_id instead and a single shared
agent_id.
409 stale_write on update. PATCH /v1/memories/{id}
accepts an optional expected_updated_at field for optimistic
concurrency. If you pass it and the memory has changed since you last
read it, the server returns 409 stale_write. Either omit the
field (last-write-wins) or re-fetch before retrying.
Step 1: export from Mem0
Export with Mem0's list call, get_all, page by page. Mem0
requires a filters argument, so list your own end users and
read each one. Save every page's results in one file as
{"results": [...]}: each item carries the text under
memory, plus user_id, metadata and
created_at, which map onto the Korely batch payload almost
field for field.
import jsonfrom mem0 import MemoryClient
m = MemoryClient(api_key="m0-...")user_ids = ["customer-4812", "customer-5113"] # your own user list
export = {"results": []}for uid in user_ids: page = 1 while True: r = m.get_all(filters={"user_id": uid}, page=page, page_size=100) export["results"] += r["results"] if not r.get("next"): break page += 1
json.dump(export, open("mem0_export.json", "w"))print(len(export["results"]), "memories exported")
One thing to know before you import. Mem0 keeps the statements it
extracted (for example Prefers invoices as PDF), not the
conversation they came from. Each statement becomes one Korely memory, and
Korely extracts its facts from that short text. If you still hold the
original conversations, importing those gives Korely more to work with.
Step 2: import with the batch endpoint
POST /v1/batch accepts up to 500 memory objects per request,
same shape as POST /v1/memories, processed asynchronously.
You get a job id back and poll it until it completes. Each imported item
runs the full write pipeline: embeddings, entity extraction, typed-fact
extraction with contradiction checking and bi-temporal validity. The model
work is included in your plan: a memory counts one write per
6,000 characters of its text.
Every memory of a batch must name a user_id or an agent_id: one without either refuses the whole batch with 422 identity_required, naming the item (why). The script below gives an item that has no owner in the export the id in FALLBACK_USER_ID; choose it yourself (for one person's data, any stable id will do).
import json, time, requests
export = json.load(open("mem0_export.json"))FALLBACK_USER_ID = "imported-from-mem0" # your choice: used when an item has no user_id
def to_korely(item): memory = { "content": item["memory"], "user_id": item.get("user_id") or FALLBACK_USER_ID, "metadata": {**(item.get("metadata") or {}), "source": "mem0_export"}, } if item.get("created_at"): # when it was true: the facts take it as valid_from, not the import day memory["timestamp"] = item["created_at"] return memory
memories = sorted((to_korely(i) for i in export["results"]), key=lambda m: m.get("timestamp", ""))
def send(chunk): while True: r = requests.post( "https://api.korely.ai/v1/batch", headers={"Authorization": "Bearer kor_live_..."}, json={"memories": chunk}, ) if r.status_code != 429: r.raise_for_status() return r.json() code = r.json().get("code") if code == "rate_limit_exceeded": # wait as long as the server says time.sleep(int(r.headers.get("Retry-After", "60"))) else: # quota_exceeded or agent_cap_exceeded: stop raise SystemExit(r.json()["message"])
# POST /v1/batch takes up to 500 memories per requestfor i in range(0, len(memories), 500): print(send(memories[i : i + 500])) # {"id": "job_4e1aa0", "status": "pending", "received": 500}Poll the job until it reports completed:
curl https://api.korely.ai/v1/batch/job_4e1aa0 \ -H "Authorization: Bearer kor_live_..."
# 200 OK{"id": "job_4e1aa0", "status": "completed", "received": 500, "imported": 500, "failed": 0, "errors": []} Keep the import inspectable. The import above tags every
migrated memory in metadata with
{"source": "mem0_export"}, so you can filter them later
with the metadata filter of
POST /v1/memories/search
(korely.search(..., metadata={"source": "mem0_export"})) or
verify the import scope with a targeted search.
Step 3: verify with search
Pick a query you know your old corpus can answer and run it scoped to one
end user. Memory search is semantic vector retrieval, cosine similarity
over embeddings. The only model work on the read path is embedding your
question, which runs on Korely's own servers. No generative model
composes the output. Your agent's own model does the reasoning over the
results. For recall assembled from the typed-fact layer rather than raw
snippets, reach for GET /v1/context, see below.
curl -X POST https://api.korely.ai/v1/memories/search \ -H "Authorization: Bearer kor_live_..." \ -H "Content-Type: application/json" \ -d '{"query": "invoice preferences", "user_id": "customer-4812", "limit": 5}'
# 200 OK{ "results": [ {"id": "mem_3fa1c9", "score": 0.91, "snippet": "Prefers invoices as PDF, replies fastest before 10am CET.", "user_id": "customer-4812", "agent_id": null, "metadata": {"source": "mem0_export"}} ]}
Extraction runs after the batch job has stored the memories. Once
GET /v1/events reports processing: 0, a
GET /v1/facts call against a migrated entity confirms the
typed layer is populated.
The recall path your agent should default to is
GET /v1/context. It returns a ready-to-inject block
assembled from the typed facts most relevant to the query (up to 10) plus the most relevant memories,
the differentiator over a raw snippet list. Call it with
query, user_id (optional agent_id,
token_budget, default 800) and you get back
{"context": "_The facts below are...## Known facts...## Relevant memories...", "tokens": 310, "sources": [...]},
where sources mixes the fct_ and
mem_ ids that grounded the answer. Raw
POST /v1/memories/search stays available when you want the
individual snippets and scores.
What your agents gain
A typed knowledge graph
Every imported memory goes through entity extraction. People, companies,
products, places, and concepts become nodes; relations between them become
typed edges, drawn from canonical predicates grouped into families
(preferences, people, places, work, ownership, health, financial, events,
identity, and an other bucket for predicates outside the named groups).
The graph is queryable: GET /v1/facts?entity=... returns every
typed fact touching an entity, its neighbourhood in the graph, so you can
walk from any subject to what it is connected to. These reads are
deterministic SQL with zero AI calls and no embeddings.
# list all memories for a user (SDK)
memories = korely.get_all(user_id="customer-4812")
# or over REST
# GET /v1/memories?user_id=customer-4812&limit=20
# check the graph layer with GET /v1/facts?user_id=customer-4812
# {"facts": [{"subject": "customer-4812", "predicate": "prefers", "predicate_raw": "prefers",
# "object": "PDF invoices", "predicate_family": "preferences", "invalid_at": null, ...}], "total": 1} See the graph for a full explanation of how entities connect memories and how to traverse those connections over the REST API.
Bi-temporal facts
Each memory also yields typed (subject, predicate, object) triples, and
every triple carries valid_from and invalid_at.
When new information contradicts an existing fact, a two-stage
contradiction check at write time invalidates the old fact and keeps it as
history. Default reads return only what is true now; pass
include_invalidated=true for the full supersede chain, or
as_of on GET /v1/facts for a point-in-time view.
Facts reads are deterministic SQL, with no model call, and return flat
JSON, {"facts": [...], "total": N}, with each fact's
phrase as extracted preserved in predicate_raw alongside the
predicate, the relation that phrase resolved to in the end
user's vocabulary (usually the same words in snake_case). The temporal engine runs on every tier, and
GET /v1/facts / korely.get_facts() work on every
plan, free hobby tier included. The full model is in
temporal facts.
Memory your end users can see
Every fact is a readable triple, so your product can show a user what the
agent knows (get_profile) and let them fix it:
correct_fact supersedes a wrong fact, forget_fact
closes one, and delete_all erases everything about the user,
for good, with an audit record. The dashboard has Correct and Forget on
every current fact. If agents carry long-term memory about people, the people get
the delete button. Read
human in the loop
for the full model.
Underneath all three: reads are retrieval, not generation. The intelligence runs once, at write time, which is why read quotas are a hundred times or more the write quotas. Quotas for each plan are on the pricing page.
Next steps
- Quickstart: wire Korely into your stack and run your first search in five minutes.
- API reference: the complete REST contract behind every call in the mapping table.
- Architecture: where the write-time intelligence runs and why the read path stays deterministic.
- Questions about a large corpus or an unusual export shape? Email [email protected]. We read every message.