Korely

SDK

korely-memory is the Python and Node.js SDK of Korely: a typed client over the REST API. Each method calls one endpoint, and the JSON fields in the API reference are the attribute names here. The CLI uses the same client and the same key.

Quick start

You need Python 3.9 or later, or Node.js 18 or later, and an API key (kor_live_...) from Quickstart. Install the package from PyPI or npm:

Terminal window
pip install korely-memory

The script saves two memories for the end user dana: where the user lives, then where the user lives now. It waits while Korely extracts the facts, then prints the context block for a question. In Node, save it as a .mjs file, or set "type": "module" in package.json: it uses import and top-level await.

import time
from korely_memory import Korely
korely = Korely(api_key="kor_live_...")
korely.add("User lives in Lisbon", user_id="dana")
korely.add("User now lives in Berlin", user_id="dana")
time.sleep(20) # extraction usually takes about ten seconds
ctx = korely.get_context("where does the user live", user_id="dana")
print(ctx.context)
# A note for your model, then:
# ## Known facts
# - The user lives_in Berlin (since 2026-11-01)
#
# ## Relevant memories
# ...

Each add() returns at once, and a model then writes the facts. When it judges that the Berlin fact contradicts the Lisbon one, Korely closes the Lisbon fact and keeps it as history, so Known facts lists only Berlin. The Lisbon memory can still appear under Relevant memories, marked "(true when said, changed later)". The model picks the relation name, so you may see another one than lives_in.

If Known facts is missing, extraction has not finished: wait and run get_context again. If it still lists Lisbon, call get_facts(user_id="dana", include_invalidated=True) to see each fact and whether it is closed.

Terms

The Glossary defines the words used across the docs. This page adds seven.

  • Client: the Korely object you create with your key. Each method sends one HTTP request; iter_audit sends one per page.
  • Receipt: what a delete or a forget returns: counts and an audit_id, never the text.
  • Page: a list result with a total, such as the MemoryPage of get_all(). total counts every match, before limit and offset.
  • Positional and keyword-only: in Python the first argument (the content, query, id or name) goes by position. A * in a Python signature means every parameter after it is passed by name, as in user_id="dana".
  • Options object: in Node.js, the other parameters go in one object with snake_case keys, as in { user_id: "dana" }. getContext takes its query inside it too.
  • Invalidated: the word field names use for closed, as in invalid_at, include_invalidated and facts_invalidated.
  • SDK errors: KorelyError is the base class of every error the SDK raises; an APIError is one the server answered, with a status and a code. See Errors.

Reference

Client

from korely_memory import Korely
korely = Korely(api_key="kor_live_...")
# Or with no key: KORELY_API_KEY, then the key
# that `korely init` saved in ~/.korely/config.json
korely = Korely()
  • Key. With no key found, the constructor raises KorelyError. It makes no request: ping() checks a key. A key belongs to one project and reads and writes only its memories and facts (Projects).
  • Address. base_url (Node: baseUrl), then KORELY_BASE_URL, then the config file, then region. On the Cloud you set none of them.
  • Timeout and retries. A request times out after 30 seconds (Python timeout, in seconds; Node timeoutMs). The SDK never retries. On rate_limit_exceeded or a 503, wait retry_after seconds and call again. On quota_exceeded, do not retry: wait for resets_at or change plan.
  • Async. In Python, AsyncKorely has every method as a coroutine (async for over iter_audit). In Node, every method returns a promise, except iterAudit, an async generator you read with for await.

Three things are called region:

NameWhat to know
The client's region argumentThe API address. Leave it out.
The project's regionWhich model reads your text to write the facts: global (the default) uses a model from Google, outside the EU; eu uses Scaleway in Paris. Set it on the Projects page of the dashboard.
region in ping()Where your data is stored: Korely's servers in Helsinki.

Methods

Python names and signatures; a ? marks an optional parameter. Each example stands alone. Node names are camelCase (get_all is getAll), with the options in one object. In Node, an unknown option name throws a KorelyError: userId gets the hint user_id. A method that changes data needs the memories:write permission, one that reads needs memories:read; ping needs neither, and init_agent needs no key.

MethodREST endpointReturns
add(content, *, user_id?, agent_id?, run_id?, metadata?, timestamp?, kind?)POST /v1/memoriesMemory: id, status, kind, facts (empty while processing)
search(query, *, user_id?, agent_id?, run_id?, metadata?, limit?, min_score?, exclude_sessions?)POST /v1/memories/searchlist of SearchHit: id, score, snippet, user_id, agent_id, metadata
get_all(*, user_id?, agent_id?, run_id?, limit?, offset?, exclude_sessions?)GET /v1/memoriesMemoryPage: memories, total
get(memory_id)GET /v1/memories/:idMemory, with its facts
update(memory_id, *, content, expected_updated_at?)PATCH /v1/memories/:idMemory, status processing
delete(memory_id)DELETE /v1/memories/:idDeleteReceipt: id, status, facts_invalidated, audit_id
delete_all(*, user_id)DELETE /v1/users/:user_id/memoriesBulkReceipt: user_id, memories_deleted, facts_deleted, erasure, audit_id
batch(memories)POST /v1/batchBatchJob: id, status, received
batch_status(job_id)GET /v1/batch/:idBatchJob: status, received, imported, failed, errors
events(*, user_id?, status?, limit?)GET /v1/eventsEventsResponse (a dict): events, processing
history(memory_id)GET /v1/memories/:id/historyMemoryHistory: events
get_facts(*, entity?, subject?, predicate?, predicate_family?, user_id?, agent_id?, as_of?, known_as_of?, include_invalidated?, limit?, offset?, exclude_sessions?)GET /v1/factslist of Fact, with .total
add_fact_triple(subject, predicate, object, *, user_id?, agent_id?, run_id?, subject_type?, object_is_literal?, confidence?, valid_from?, tense?)POST /v1/factsFact, with invalidated
correct_fact(fact_id, *, subject?, predicate?, object?)PATCH /v1/facts/:idFact, the current one
forget_fact(fact_id, *, at?)POST /v1/facts/:id/forgetForgetReceipt (a dict): id, status, invalid_at, audit_id
declare_name(name, *, user_id, agent_id?)POST /v1/users/:user_id/namesDeclaredName: user_id, name, agent_id, since
get_context(query, *, user_id?, agent_id?, token_budget?, known_as_of?, exclude_sessions?)GET /v1/contextContext: context, tokens, sources and more
get_profile(*, user_id, agent_id?, as_of?, known_as_of?, exclude_sessions?)GET /v1/profileProfile: user_id, as_of, known_as_of, facts, by_family, total, truncated
users(*, agent_id?, limit?, offset?)GET /v1/usersUsersPage: users, total
list_agents(*, limit?, offset?)GET /v1/agentsAgentsPage: agents, total, cap, used
delete_agent(agent_id)DELETE /v1/agents/:agent_idAgentDeleteReceipt: agent_id, memories_deleted, facts_deleted, slot_freed, audit_id
audit(*, user_id?, action?, since?, until?, limit?, offset?)GET /v1/auditAuditPage: events, total
iter_audit(*, user_id?, action?, since?, until?, page_size?, offset?)GET /v1/audit, page after pageiterator of AuditEvent
ping()GET /v1/pingPingResponse: ok, tier, region, processing_region, scopes
delete_account(*, confirm)DELETE /v1/account?confirm=trueAccountDeleteReceipt: deleted, removed
Korely.init_agent(agent_caller?, *, processing_region?, region?, base_url?, timeout?)POST /v1/agents/init, no keyAgentInitResult: api_key, tier, region, processing_region, scopes, quotas

add

Saves a memory and returns it at once, with status processing and an empty facts list. The facts follow, usually about ten seconds later.

  • content: a string, or a list of chat messages (role and content) joined into one text.
  • user_id, agent_id, run_id: any combination (Scoping).
  • metadata: your own labels, returned unchanged.
  • timestamp: the ISO date the events happened; the facts take it as valid_from.
  • kind: note (the default) or session (Work sessions).
memory = korely.add(
"Northwind Hosting costs 50 euro per month since the June upgrade.",
agent_id="infra-bot",
user_id="customer-4812",
metadata={"source": "slack"},
)
print(memory.id) # mem_8f2c1a
print(memory.status) # processing: facts are extracted after the call returns
print(memory.facts) # []
# About ten seconds later
facts = korely.get_facts(entity="Northwind Hosting", user_id="customer-4812")
print(facts[0].subject, facts[0].predicate, facts[0].object)
# Northwind Hosting costs 50 euro per month

Read the facts once get(memory.id).status is ready. If the list is then empty, extraction wrote no facts; error means extraction failed (events).

Work sessions: kind and exclude_sessions

Every memory has a kind. note, the default, is what somebody said or decided. session is the log of a work session, such as the notes a coding assistant saves at the end of a session. Both produce facts. A read with exclude_sessions leaves out the work sessions and the facts only they state. search, get_all, get_facts, get_profile and get_context take it; it is off by default.

# The log of a work session, saved as what it is
korely.add(
"Session: moved the billing service to Postgres 16, tests pass, rollback script in ops/.",
user_id="dev-team",
kind="session",
)
# A decision, saved as a note (the default kind)
korely.add("We bill in euro only, agreed with finance.", user_id="dev-team")
# Reads that leave the work sessions out
ctx = korely.get_context("how do we bill customers", user_id="dev-team", exclude_sessions=True)
hits = korely.search("billing currency", user_id="dev-team", exclude_sessions=True)
# Each memory says which kind it is (newest first)
page = korely.get_all(user_id="dev-team")
print([m.kind for m in page.memories]) # ['note', 'session']

A search finds the memories closest in meaning to the query, by their embeddings. Each hit has a score. For recall, get_context is usually the better call: it returns the current facts and the memories as one block.

  • agent_id, user_id, run_id, exclude_sessions (Work sessions).
  • metadata: every key must match. A value matches as text, and a number also as a number.
  • limit: 15 by default, 50 at most. min_score: 0 to 1, no floor by default.

All filters combine with AND.

results = korely.search(
"northwind pricing",
user_id="customer-4812",
limit=5,
)
for hit in results:
print(hit.id, hit.score, hit.snippet)
# mem_8f2c1a 0.91 Northwind Hosting costs 50 euro per month...

get_all

Lists the memories of a scope, newest first, with no query and no model call. limit is 50 by default and 200 at most; page with offset. A listed memory has an empty facts list even when it is ready: read facts with get or get_facts.

page = korely.get_all(
user_id="customer-4812",
limit=50,
offset=0,
)
print(page.total) # 218
for memory in page:
print(memory.id, memory.created_at)
# mem_8f2c1a 2026-06-07T09:14:00.412345+00:00

get

One memory: its content, metadata and the facts from it that are true now. Closed facts are in its history.

memory = korely.get("mem_8f2c1a")
print(memory.content) # Northwind Hosting costs 50 euro per month...
print(memory.metadata) # {"source": "slack"}
print(memory.created_at) # 2026-06-07T09:14:00.412345+00:00

update

Replaces the content and runs extraction again. As with add, it answers processing with an empty facts list. An edit counts writes like an add: one per 6,000 characters of the new text.

An update can close facts. A new value closes the old fact even if another memory still states it: the latest edit wins. If extraction finds no facts in the new text, the memory's old facts are closed.

expected_updated_at refuses an update when another writer got there first: the call raises StaleWriteError (409 stale_write). Copy the value, never type it: pass the updated_at the API returned, or created_at before the first edit. The server compares it to the microsecond, so a rounded timestamp is refused.

current = korely.get("mem_8f2c1a")
memory = korely.update(
"mem_8f2c1a",
content="Northwind Hosting costs 55 euro per month after the storage add-on.",
# the exact value the API returned, never one typed by hand
expected_updated_at=current.updated_at or current.created_at,
)
print(memory.status) # processing
print(memory.facts) # [], the new facts arrive about ten seconds later
# Read them afterwards (newest first)
facts = korely.get_facts(entity="Northwind Hosting", user_id="customer-4812")
print(facts[0].object) # 55 euro per month

delete

Hides one memory: it leaves every read, the facts only it stated are closed and kept as history, and a fact another memory also states stays open. The audit_id is a receipt for your logs. To erase the memory for good, call DELETE /v1/memories/:id?erase=true through the REST API: the SDK has no method for it.

The receipt's status is forgotten, which means hidden. If this memory's fact had closed an older fact, the delete can make the older fact true again, as a new open fact (Delete a memory). The service counts these in facts_reopened. Only Node returns that count today, without a type; Python 0.1.28 leaves it out.

receipt = korely.delete("mem_8f2c1a")
print(receipt.status) # forgotten
print(receipt.facts_invalidated) # 1
print(receipt.audit_id) # aud_3d0f

delete_all

Erases for good every memory and fact of one end user in the key's project, closed facts included, in one call with one audit record. Memories saved without a user_id stay. erasure is always "permanent". See Forget a user.

receipt = korely.delete_all(user_id="customer-4812")
print(receipt.memories_deleted) # 218
print(receipt.facts_deleted) # 64
print(receipt.audit_id) # aud_91xb

batch and batch_status

Imports up to 500 memories in one call, for a migration. Each item is shaped like an add: content, and optionally user_id, agent_id, run_id, metadata, timestamp, kind. An unknown key refuses the whole batch with a 422. Each item counts writes as on add, one per 6,000 characters.

The job runs in the background: its status goes from pending to processing, then completed or failed. Poll batch_status until it ends.

job = korely.batch([
{"content": "Prefers async standups over meetings.", "user_id": "customer-0001"},
{"content": "Renewal date moved to October 1st.", "user_id": "customer-0002"},
])
print(job.status) # pending
# Later
job = korely.batch_status(job.id)
print(job.status) # completed
print(job.imported) # 2

events

Which writes have finished extraction. Each event has a memory_id and a status: processing, ready or error. The processing count is the number of writes still being extracted, so a script can wait on one number. limit is 50 by default, 200 at most. The memories of a batch() appear here only once the job has stored them: check batch_status() first.

result = korely.events(user_id="customer-4812")
print(result["processing"]) # 0
for e in result["events"]:
print(e["memory_id"], e["status"]) # mem_8f2c1a ready

history

The lifecycle of one memory: the events created, updated, fact_extracted, fact_invalidated and deleted, each with its time. It answers for a hidden memory too. To follow one fact's chain of versions, use get_facts with include_invalidated=True.

h = korely.history("mem_8f2c1a")
for event in h.events:
print(event.event, event.at) # created ... / fact_extracted ...

get_facts

Typed facts (subject, predicate, object) with their two clocks (valid time and known time), as a flat list. A database read, no model call, on every plan. Pass as_of for what was true on a date, and known_as_of for what Korely knew on a date.

  • entity (an entity) matches either side of the triple.
  • predicate is the relation name Korely normalizes, such as lives_in; predicate_raw is the wording as extracted. predicate_family groups relations into ten families (preferences, people, places, work, ownership, health, financial, events, identity, other). Korely picks the family, so filter by entity when you are not sure of it.
  • include_invalidated=True adds the closed facts.
  • limit is 50 by default and 200 at most; .total is the count before paging.
# Current state: only open facts
facts = korely.get_facts(entity="Northwind Hosting")
print(facts[0].object) # 50 euro per month
print(facts[0].invalid_at) # None, open
# Point-in-time: what was true on June 1?
facts = korely.get_facts(entity="Northwind Hosting", as_of="2026-06-01")
print(facts[0].object) # 40 euro per month
print(facts[0].invalid_at) # 2026-06-07T09:14:00+00:00, closed since
# What Korely knew on June 1. It learned of the change on June 7,
# so on June 1 the 40 euro fact was still open.
facts = korely.get_facts(entity="Northwind Hosting", known_as_of="2026-06-01")
print(facts[0].object) # 40 euro per month
print(facts[0].closed_known_at) # None, open as of June 1

Each fact has the fields of Get facts. Besides the triple, the scope and the two clocks (valid_from, invalid_at, created_at, closed_known_at):

  • subject_type, object_is_literal, confidence, tense: as in add_fact_triple.
  • subject_canonical, object_canonical: the names after declared names apply, such as maria for Maria Rossi; subject and object keep the words as written.
  • source_memory_id: the memory it was extracted from. source_memory_ids: every memory that stated it.
  • observation_count: how many memories stated it; last_confirmed_at: when one last stated it again.
  • invalidated_by: the id of the fact that closed it, when there is one.

See Temporal facts for how facts close.

add_fact_triple

Writes a fact directly, without extraction, when your agent already has the subject, predicate and object. The contradiction check still runs: it calls a model, and the call counts as one write. Pass valid_from for a fact that began in the past. The fact it returns carries only the fields the write knows; read it again with get_facts for the rest. The optional fields:

  • subject_type: what the subject is, such as person or organization; unknown by default.
  • object_is_literal: True when the object is a value (a date, an amount), False (the default) when it names an entity.
  • confidence: a number from 0 to 1, 0.9 by default. You can ignore it at first.
  • tense: current (the default), past (the fact is over, and it closes the open fact it restates) or planned (an intention).
fact = korely.add_fact_triple(
"Marco", "works_at", "Acme GmbH",
user_id="customer-4812",
subject_type="person",
valid_from="2026-06-01",
)
print(fact.invalidated) # ids of any facts this one closed

correct_fact

Replaces a fact with a corrected one: pass at least one of subject, predicate, object. The old fact is closed, not edited, so as_of before the correction still returns what you believed then. It counts as one write. A correction that states the fact as it already is returns the same fact, with invalidated empty.

fact = korely.correct_fact("fct_a1", object="Beacon Labs")
print(fact.object, fact.invalid_at) # Beacon Labs None

forget_fact

Closes a fact: it leaves default reads and stays in history (include_invalidated=True still shows it). Pass at (ISO date) for when it stopped being true; the default is now, and a date before the fact's valid_from is a 422. A second call is safe: it answers already_forgotten.

receipt = korely.forget_fact("fct_9c1e", at="2026-06-30")
print(receipt["status"]) # forgotten

declare_name

Tells Korely another name of an end user: the id maria is Maria Rossi. Korely never merges two names by itself, because two people can share a name. After the declaration, the facts about Maria Rossi in that end user's memory, old and new, read as facts about maria; their words stay as written.

declared = korely.declare_name("Maria Rossi", user_id="maria")
print(declared.user_id, declared.name) # maria Maria Rossi
print(declared.since) # 2026-10-18T21:04:11.412345Z
# The facts about "Maria Rossi" now come back under maria
facts = korely.get_facts(user_id="maria", subject="maria")

Call it once per name, 1 to 200 characters. Declaring the same name again answers the same. A name already declared for somebody else in that memory raises ConflictError (409 conflict). Declaring the user_id itself as a name is a 422. Pass agent_id to declare the name in one agent's memory of that end user. The CLI does the same with korely init --name.

get_context

Returns a context block ready for a prompt: the known facts most relevant to the query, then the relevant memories, within a token budget. It is retrieval and formatting, not generation.

  • query: the current user turn or topic. In Node it goes inside the options object.
  • token_budget: 800 by default, from 50 to 8,000.
  • known_as_of: a date, such as "2026-06-01". The block then holds only what Korely had learned by that day, so you can check what an agent could have known when it decided.
  • exclude_sessions=True: a block without the work sessions.

Besides context, tokens and sources (the ids of the facts and memories used), Context has:

  • stable, volatile, stable_hash: context split in two. stable is the part that does not depend on the question, the same on every call; volatile is the rest; stable_hash changes only when stable changes. Use them to cache a fixed prompt start; otherwise use context.
  • degraded: true when part of the block could not be retrieved. degraded_parts says which: facts, memories or both.

Under heavy load the call waits up to 20 seconds, then can return a partial block with degraded true; it still counts as a read. Check degraded: when it is true, call again later for the full block.

ctx = korely.get_context(
"plan infra budget",
user_id="customer-4812",
token_budget=800,
)
print(ctx.tokens) # 642
print(ctx.sources) # ["fct_b91e", "mem_8f2c1a"]
messages = [
{"role": "system", "content": f"You are a helpful assistant.\n\n{ctx.context}"},
{"role": "user", "content": user_message},
]

get_profile

The open facts about one end user, the end user's own facts first, grouped by family. Pass as_of for the profile on a past date, known_as_of for the profile as Korely knew it then, and exclude_sessions=True to leave out the facts only work sessions state.

profile = korely.get_profile(user_id="customer-4812")
print(profile.total) # 7
print(list(profile.by_family)) # ["places", "work", "preferences"]
# The profile as it stood on March 1st
past = korely.get_profile(user_id="customer-4812", as_of="2026-03-01")

users

The end users you have stored data for, each with counts of memories and open facts. Page with limit and offset.

page = korely.users()
print(page.total) # 1
for u in page:
print(u.user_id, u.memories, u.facts)

list_agents

The agent_id values you have written under, each with counts and last activity, plus your plan's agent cap and how many are used. Call it when a write fails with agent_cap_exceeded, to reuse an existing id.

page = korely.list_agents()
print(page.used, "of", page.cap) # 2 of 2
for a in page:
print(a.agent_id, a.memories, a.facts)

delete_agent

Deletes for good every memory and fact written under one agent_id in the key's project. Its slot in your plan's agent cap is freed (slot_freed) unless another project of the account still uses the same name.

receipt = korely.delete_agent("staging-bot")
print(receipt.memories_deleted, receipt.facts_deleted, receipt.audit_id)

Audit, ping and account

  • audit: the audit log of the key's project, newest first. since and until: ISO text, datetime or date. limit 1 to 1,000, 100 by default. It counts against no monthly quota.
  • iter_audit: every page of audit, to read the whole log. Without until, it pins until when it starts.
  • ping: checks the key. No permission and no monthly quota, but it counts toward the rate limit.
  • delete_account: Cloud only, for an account made by init_agent() or korely init --agent. Deletes it with every key, memory and fact; confirm=True is required. An account that has a dashboard login raises ConflictError (account_has_login). On an account without a login, an unconfined key with the admin permission (every init key since 1.0.5) closes the whole account; any other key gets 403: read the warning before you give such a key to code you do not trust.
  • Korely.init_agent: Cloud only, a class method that needs no key. It opens an account on the free Hobby plan and returns its key, shown once (save it). agent_caller is an optional label, such as your app's name. processing_region, "eu" or "global", sets the new project's region, global if left out. Node: Korely.initAgent(agentCaller, { processingRegion }).

Projects and keys (admin key)

The Python client also manages projects and keys: projects, create_project, update_project, archive_project, unarchive_project, delete_project, keys, create_key, revoke_key, move_key. They need an admin key: use it by hand, never in a tool your agent can call. Projects and keys are managed from the dashboard or with an admin key. The Node client does not have them.

Scoping

agent_id names your app, user_id the end user, and run_id one session. A typical setup is one agent_id per product surface and one user_id per customer. The number of agent_id values is capped by your plan; the number of end users is not.

Always pass user_id on reads in multi-tenant products. A read without user_id covers every end user the key can see in the project (narrowed to one agent_id if you pass it). That suits an internal ops agent, not a customer-facing chat.

Errors

Every error response carries {"code": "...", "message": "..."}, and the SDK raises it as an APIError with .status, .code, .message and .retry_after (Node: .retryAfter), the server's Retry-After in seconds when it sends one. Python also keeps the server's answer in .body. A KorelyError that is not an APIError never got an answer: no key, a timeout, a connection error. Subclasses by status:

  • AuthenticationError: 401. NotFoundError: 404.
  • NamespaceForbiddenError: every 403 (the forbidden rows below).
  • ConflictError: 409, with the subclass StaleWriteError for stale_write.
  • QuotaExceededError: 429, with limit, used and resets_at (Node: resetsAt) on quota_exceeded. Its subclass TooManyBatchesError is too_many_batches.

Branch on err.code:

StatuscodeWhen
401invalid_keyMissing, malformed or revoked API key. Message: "Invalid or missing API key: ...", then what is wrong.
403forbiddenThe key lacks the permission the call needs.
403forbiddenA confined key names an end user it is not confined to.
403forbiddenA confined key writes with no user_id, and it is confined to more than one end user.
403forbiddenA confined key calls delete_agent, which spans every end user.
404not_foundThe memory does not exist, or was hidden. Message: "Memory not found".
408request_timeoutThe body did not arrive whole in time: 30 seconds, or 120 seconds for a batch() body over 2,097,152 bytes. Nothing was done; send it again, or in smaller batches.
409stale_writeupdate with an expected_updated_at that is not the current version (StaleWriteError).
409fact_not_currentcorrect_fact on a closed fact. current_fact_id (Node: currentFactId) is the fact to correct instead, or None/undefined when nothing took its place.
409account_has_logindelete_account on an account that has a dashboard login.
409conflictdeclare_name with a name already declared for somebody else in that memory.
413payload_too_largeThe body is over 2,097,152 bytes (a batch: 78,245,152). Nothing was done. On the Cloud a body over 1 MB (80 MB for a batch) gets a bare 413 first, without this code.
422invalid_requestValidation failure, such as "content: Field required".
429quota_exceededThe monthly write quota (for writes) or query quota (for reads) is used up, including the 10% extra. No Retry-After.
429rate_limit_exceededToo many requests in the current minute, hour or day. Retry-After in whole seconds.
429agent_cap_exceededA new agent_id would pass your plan's agent cap. Carries limit and used.
429too_many_batchesCloud: three batch() imports of the account are still running. Nothing was stored; send it again when one finishes. It is not a monthly quota.
500internal_errorThe request failed on our side.
503search_unavailable, model_unavailable, service_paused, unavailableSearch could not run, a model gave no usable answer, the service has paused model calls (service_paused, Retry-After: 3600), or the database was too slow (unavailable). Nothing was written; retry.
from korely_memory import Korely, APIError
korely = Korely(api_key="kor_live_...")
try:
memory = korely.get("mem_8f2c1a")
except APIError as err:
if err.code == "invalid_key":
# 401: check the key, or create a new one in the dashboard
raise
elif err.code == "not_found":
# 404: the memory does not exist, or an end user forgot it
memory = None
elif err.code == "quota_exceeded":
# 429: monthly query quota reached (get() is a read), upgrade or wait for the reset
memory = None
else:
raise

There is no overage billing: a plan stops at its limit. At 80% of the month's writes you get a quota.warning webhook (none for queries). Past 100% you can still write 10% more, so a busy day does not break your agent. Past that, writes return 429 quota_exceeded until the month rolls over at resets_at, you buy extra writes for the month on a paid plan (a top-up), or you upgrade. Your bill is the plan price plus any top-up.

End-to-end example

A support bot that remembers each customer across sessions. On every turn it reads a context block, calls the model, then stores what the user said: get_context, the model call, add.

The model here is Google's, so install its client next to Korely's, and set two keys: KORELY_API_KEY (your Korely key from Quickstart) and GOOGLE_API_KEY (a key from Google). The Node version is TypeScript and, like the Quick start, needs "type": "module" in package.json.

Terminal window
pip install korely-memory google-genai
"""
Support bot with persistent per-customer memory.
Uses korely-memory for context recall and fact storage.
"""
import os
import time
from google import genai
from korely_memory import Korely, APIError
korely = Korely() # reads KORELY_API_KEY
client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])
AGENT = "support-bot"
def chat(user_id: str, user_message: str) -> str:
# 1. Assemble memory context for this user + query
ctx = korely.get_context(
user_message,
agent_id=AGENT,
user_id=user_id,
token_budget=600,
)
system = (
"You are a concise support assistant. "
"Use the memory context below to personalise your reply.\n\n"
+ ctx.context
)
# 2. Call the LLM
reply = client.models.generate_content(
model="gemini-3.6-flash",
contents=system + "\n\nUser: " + user_message,
).text
# 3. Persist what the user said (extracts facts, detects contradictions)
try:
korely.add(
user_message,
agent_id=AGENT,
user_id=user_id,
metadata={"turn": "user"},
)
except APIError as err:
if err.code != "quota_exceeded":
raise
pass # over the write quota, log and continue, the reply was already generated
return reply
# --- run a two-turn session ---
uid = "customer-4812"
print(chat(uid, "Hi, I am on the Developer plan and I prefer async updates."))
# context is empty on first turn, the bot greets + confirms
time.sleep(20) # let extraction write the facts of turn 1
print(chat(uid, "What plan am I on again?"))
# the context block now contains the plan + preference facts from turn 1

On turn 1, add extracts facts such as the customer's plan and preference. On turn 2, get_context puts them in the system prompt before the model sees the question.

Details

  • Extraction time. Usually about ten seconds; a longer text takes longer. A memory can end ready with no facts. Polling has a cost: each get_facts or events call is a read, counted as a query and toward the rate limit.
  • Changes in the present tense. Write a change as the new state, such as "now lives in Berlin": Korely does not always recognize a change told as a past event.
  • No model on reads. Your agent's model does the reasoning. The only model call on a read is the embedding of a search query, on our own server. Models run on writes, so read quotas are a hundred times or more the write quotas.
  • ping() and regions. The client's only region is eu, https://api.korely.ai. ping() returns region, a label such as eu-hel1, and processing_region, the project's region. A new project is global (Regions); init_agent(processing_region=...) sets it at signup.
  • Versions. Check yours with pip show korely-memory or npm ls korely-memory. kind, exclude_sessions and declare_name: Python 0.1.26, Node 0.1.16. Memory.kind and kind on a batch item: Python 0.1.27, Node 0.1.17. known_as_of: Python 0.1.22, Node 0.1.14. degraded and the other Context fields after sources: Python 0.1.18. limit, used, resets_at on a quota error: Python 0.1.20, Node 0.1.12.
  • Self-hosted. An install older than Korely 0.1.42 refuses kind with a 422 and sends no kind back, so Memory.kind is empty (None in Python, absent in Node). Embeddings, extraction and contradiction checks run on the server (ours on the Cloud, yours on Self-hosted), so the SDK is a thin HTTP client with no heavy dependencies.
  • Migrating? See the migration guide.

Related

TopicWhere to go
Full REST contract API reference, the source of truth.
CLI CLI reference: the same memory from the terminal.
MCP MCP reference: connect Claude, Cursor or any MCP client.
Facts over time Temporal facts: how contradictions close facts and what as_of returns.
Chatbot Cookbook: chatbot that remembers, a fuller version of the end-to-end example.
Bulk import Batch import (API reference): progress tracking and per-item errors.
Surfaces When to use SDK, CLI or REST.
Pricing and quotas Pricing: hobby (free), developer (€19/mo + VAT), team (€79/mo + VAT), scale (€249/mo + VAT). Writes and queries count; end users are always unlimited.