Korely

Migrate from Zep

If you built on Zep, the concepts transfer almost one to one. A Zep user becomes a user_id. A thread becomes a run_id. Graph search becomes memory search plus a typed facts read. And the part that usually makes migrations lossy is not lossy here: both systems are bi-temporal, so the idea that a fact has a validity interval, became true at one point and stopped being true at another, carries over intact. We cite the Zep team's paper on our research page; the two temporal models are close relatives.

The short version: three steps. Export your episodes per user with graph.episode.get_by_user_id, import them with POST /v1/batch, then run a search and a facts read against the migrated corpus. Entity extraction, graph edges, and typed facts rebuild automatically on ingest. There is nothing to enable and nothing to configure.

Concept mapping

Zep conceptKorely equivalent
User, created with user.add user_id: free-form string, no registration call, end users are unlimited on every tier
Thread, created with thread.create run_id: session scope, no create call, pass it on writes
Episode: raw data ingested verbatim A memory: POST /v1/memories
Graph edge carrying a fact Typed (subject, predicate, object) fact: GET /v1/facts
valid_at / invalid_at on an edge valid_from / invalid_at on a fact
Context block from thread.get_user_context Prompt-ready block from GET /v1/context

One structural difference: Zep asks you to register the user and create the thread before adding messages. Korely scoping identifiers are free-form strings that exist the moment you first write with them, so two of your setup calls simply disappear.

Call mapping

Every core Zep Cloud call has a Korely equivalent, in the Python SDK and over REST. The full REST contract is in the API reference.

Zep callKorely SDKKorely REST
client.user.add(user_id="u1") not needed: users exist on first write not needed
client.thread.create(thread_id="t1", user_id="u1") not needed: pass run_id="t1" on writes not needed
client.thread.add_messages(thread_id, messages=[...]) korely.add("...", user_id="u1", run_id="t1") POST /v1/memories
client.graph.add(user_id="u1", type="text", data="...") korely.add("...", user_id="u1") POST /v1/memories
client.thread.get_user_context(thread_id) korely.get_context(query="...", user_id="u1", token_budget=800) GET /v1/context
client.graph.search(query, user_id="u1", scope="episodes") korely.search(query, user_id="u1") POST /v1/memories/search
client.graph.search(query, user_id="u1", scope="edges") korely.get_facts(entity="...", user_id="u1") GET /v1/facts?entity=...&user_id=u1
client.graph.edge.get_by_user_id(user_id="u1") korely.get_facts(user_id="u1", include_invalidated=True) GET /v1/facts?user_id=u1&include_invalidated=true
client.graph.episode.get_by_user_id(user_id="u1", lastn=50) korely.get_all(user_id="u1") GET /v1/memories?user_id=u1
client.user.delete(user_id="u1") korely.delete_all(user_id="u1") DELETE /v1/users/:user_id/memories

Node-neighborhood exploration, the scope="nodes" style of query, maps to GET /v1/facts?entity=... (SDK korely.get_facts(entity="...")): you read every typed fact touching an entity, as pure SQL lookups with zero model calls. That is the deterministic graph read. For prompt-ready recall, reach for GET /v1/context, which assembles the entity's active typed facts and the most relevant memories into one block. See the graph.

In code, the switch looks like this:

korely_memory.py python
# before
from zep_cloud.client import Zep
from zep_cloud.types import Message
zep = Zep(api_key="z_...")
zep.user.add(user_id="customer-4812")
zep.thread.create(thread_id="support-118", user_id="customer-4812")
zep.thread.add_messages("support-118", messages=[
    Message(role="user", content="Prefers invoices as PDF"),
])

# after
from korely_memory import Korely
korely = Korely(api_key="kor_live_...")
korely.add("Prefers invoices as PDF",
           user_id="customer-4812", run_id="support-118")

results = korely.search("invoice preferences", user_id="customer-4812")

The Python and Node SDKs are live, pip install korely-memory and npm install korely-memory. API keys are available now on the free Hobby plan.

Gotchas

  • Backdating: pass timestamp. Zep lets you backdate an episode by passing created_at to the graph add call. In Korely, add(), POST /v1/memories and every POST /v1/batch item take a timestamp (ISO 8601) that is stored as the facts' valid_from: send each episode's created_at there, as the import below does, and replay the episodes oldest first, so write-time contradiction checking rebuilds the supersede chain in order. For structured triples you already hold, POST /v1/facts takes an explicit valid_from.
  • No include_history on search. Zep's search accepts a scope parameter that toggles between episodes, edges, and nodes. Korely keeps retrieval and facts separate: POST /v1/memories/search returns memories; GET /v1/facts returns typed triples. Pass include_invalidated=true to the facts endpoint for the full temporal history including superseded edges.
  • Agent cap vs user cap. Zep bills by user count. Korely bills by write quota and agent count. End users (user_id) are unlimited on every plan. Agents (agent_id) are capped per plan: 2 on Hobby, 10 on Developer, 100 on Team, 500 on Scale. Exceeding the agent cap returns 403 agent_cap_exceeded. If you fan out one agent per end user in Zep today, map those to a single agent_id plus per-user user_id scoping instead.
  • Write quota, not request quota. The 429 quota_exceeded response triggers when your monthly write count would pass the plan limit (with the month's top-ups) plus a 10% grace. The body is the standard {"code": "quota_exceeded", "message": "..."} envelope plus limit, used and resets_at; the write-quota 429 carries no Retry-After header (only the separate rate-limit 429 does, as integer seconds). A batch sent while three imports are still running gets 429 too_many_batches: wait for one to finish, as the import below does. Read calls (search, get_context, get_facts, history) do not count against writes.
  • Graph rebuilds automatically; nothing to configure. In Zep you pick node types and configure edge schemas. In Korely, entity extraction, typed-fact derivation, and bi-temporal contradiction checking all run at write time without schema setup. If you import raw episode text, the graph and fact store materialize from it.

Step 1: export from Zep

Episodes are the raw data Zep ingested verbatim, so they are the right thing to replay. List them per user with graph.episode.get_by_user_id. Each episode carries its content and created_at; keep both.

import json
from zep_cloud.client import Zep
zep = Zep(api_key="z_...")
user_ids = ["customer-4812", "customer-5113"] # your own user list
export = []
for uid in user_ids:
response = zep.graph.episode.get_by_user_id(user_id=uid, lastn=1000)
for ep in response.episodes:
export.append({
"user_id": uid,
"content": ep.content,
"created_at": ep.created_at,
})
json.dump(export, open("zep_export.json", "w"))
print(len(export), "episodes exported")

If parts of your graph were built from sources you no longer have, also export the edges: graph.edge.get_by_user_id returns every edge with its fact sentence, valid_at, and invalid_at. Step 2 shows how to replay them so the history survives.

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. About a tenth of a cent of intelligence per memory, all included in your plan.

import json, time, requests
export = json.load(open("zep_export.json"))
memories = [
{
"content": item["content"],
"user_id": item["user_id"],
"metadata": {"source": "zep_export"},
# when it was true: the facts take it as valid_from, not the import day
"timestamp": item["created_at"],
}
for item in sorted(export, key=lambda e: e["created_at"]) # oldest first
]
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 == "too_many_batches": # three imports still running
time.sleep(30)
elif code == "rate_limit_exceeded": # wait as long as the server says
time.sleep(int(r.headers.get("Retry-After", "60")))
else: # quota_exceeded: the month is full, stop
raise SystemExit(r.json()["message"])
# POST /v1/batch takes up to 500 memories per request
for i in range(0, len(memories), 500):
print(send(memories[i : i + 500])) # {"id": "job_7c20d1", "status": "processing", "received": 500}

Poll the job until it reports completed:

Terminal window
curl https://api.korely.ai/v1/batch/job_7c20d1 \
-H "Authorization: Bearer kor_live_..."
# 200 OK
{"id": "job_7c20d1", "status": "completed", "received": 500,
"imported": 500, "failed": 0, "errors": []}

If your episodes are complete, stop there: extraction rebuilds the graph and the fact store from the raw data. If you exported edges in step 1, replay their fact sentences sorted by valid_at, oldest first. Write-time contradiction checking then reconstructs the supersede chain in order: the old fact lands, the newer one invalidates it, and the invalidated version stays queryable as history. Agents that already hold the structured form can skip extraction entirely and write triples with POST /v1/facts, which accepts an explicit valid_from.

Keep the import inspectable. Tag migrated memories by setting "metadata": {"source": "zep-import"} on each item. Search accepts a metadata filter, so you can audit exactly what came over.

Step 3: verify with context and facts

Where Zep gave you a context block from thread.get_user_context, Korely gives you GET /v1/context: it assembles the end user's active typed facts and the most relevant memories into one prompt-ready block. This is the recall path to verify first, because it is the one your agent will use in production. No generative model composes the output; it is fact assembly plus retrieval. Your agent's own model reasons over the block.

Terminal window
curl "https://api.korely.ai/v1/context?query=invoice%20preferences&user_id=customer-4812" \
-H "Authorization: Bearer kor_live_..."
# 200 OK (the note that opens the block is shortened here)
{
"context": "_The facts below are a compact profile of the user; the memories are the verbatim source of truth. Ground your answer in this context. [...] do not abstain when it is present._\n\n## Known facts\n- The user prefers_format PDF (since 2026-05-02)\n- The user replies_fastest_before 10am CET (since 2026-05-02)\n\n## Relevant memories\n_A time in square brackets [...] is the day it was said._\n- [2026-05-02] Prefers invoices as PDF, replies fastest before 10am CET.",
"tokens": 233,
"sources": ["fct_2d4a", "fct_2d4b", "mem_5b80e2"],
"degraded": false,
"degraded_parts": []
}

For raw retrieval, POST /v1/memories/search ranks an end user's memories by semantic vector similarity (cosine over embeddings). The only model call on the read path is the query embedding, a fraction of a hundredth of a cent. The response is a flat results list; each item carries a snippet (not the full body), a score, and the scoping ids.

Terminal window
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_5b80e2", "score": 0.69,
"snippet": "Prefers invoices as PDF, replies fastest before 10am CET.",
"user_id": "customer-4812", "agent_id": null,
"metadata": {"source": "zep_export"}}
]
}

Then check the temporal layer the way you would have read edges in Zep. Where you searched with scope="edges" and read fact, valid_at, and invalid_at, you now filter facts and read valid_from and invalid_at. The response is a flat {"facts": [...], "total": N} list. A fact is live when its invalid_at is null. Korely normalizes the predicate verb, so the raw verb you wrote (prefers) is stored on predicate_raw while predicate holds the canonical form (likes):

Terminal window
curl "https://api.korely.ai/v1/facts?entity=invoices&include_invalidated=true" \
-H "Authorization: Bearer kor_live_..."
# 200 OK
{
"facts": [
{"id": "fct_2d4a", "subject": "customer-4812", "predicate": "likes",
"predicate_raw": "prefers", "object": "invoices as PDF",
"predicate_family": "preferences", "confidence": 0.9,
"valid_from": "2026-05-02T08:10:00Z", "invalid_at": null,
"invalidated_by": null, "source_memory_id": "mem_5b80e2"}
],
"total": 1
}

Point-in-time reads work the way you expect from a bi-temporal store: pass as_of with an ISO date and you get what was true on that date. The full model, including the two-stage contradiction check at write time, is in temporal facts.

What your agents gain

A managed cloud store behind the API

Every memory your agents write lands in a managed cloud store (Postgres + pgvector, EU-hosted), readable in the dashboard: the Memories page lists them, the Graph page draws the entities and facts, and the Facts page lets you correct or forget any fact. Memory stops being an opaque index you can only query through an API and becomes a corpus you can open, skim, and audit. The memories tagged in step 2 are exactly that: your migrated graph, browsable there.

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 fact. If agents carry long-term memory about people, the people get the delete button. Read human in the loop for the full model.

In both cases: reads are retrieval, not generation. The intelligence runs once, at write time, which is why read quotas are an order of magnitude more generous than 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 graph or an unusual export shape? Email [email protected]. We read every message.