CortexDB Python SDK
The Long-Term Memory Layer for AI Systems.
CortexDB gives your AI agents persistent, structured memory. Capture experiences, then recall a stratified pack (events, facts, beliefs, episodes, concepts) or get a grounded answer — all scoped to a hierarchical scope path.
This SDK targets the v1 API (/v1/experience, /v1/recall, /v1/answer, …), PASETO auth, and hierarchical scopes. The pre-0.3 remember()/tenant_id client was retired — Cortex is now an alias of V1Client.
Installation
pip install cortexdbai
Quick start
import os
from cortexdb import Cortex
# Authenticated local Docker instance (set CORTEX_API_KEY on the server too):
with Cortex("http://localhost:3141", bearer=os.environ["CORTEX_API_KEY"]) as c:
c.experience(
"ws:demo",
text="Priya at Acme signed for 200 seats. Q3 close.",
wait="indexed",
)
pack = c.recall("ws:demo", query="How many seats did Acme sign for?")
print(pack["context_block"])
Hosted / multi-tenant (PASETO bearer + actor)
The client acts as its token's subject: X-Cortex-Actor is the token's sub
unless you pass actor (actor=None sends no header, as a token whose
subject the server maps itself needs).
with Cortex(
"https://api-v1.cortexdb.ai",
bearer="v4.public…", # PASETO v4 public or JWT (RS256/ES256); acts as its sub
) as c:
out = c.answer(
"org:initech/user:alice",
"What did Alice say about coffee?",
)
print(out["answer"])
One-call signup (no prior credentials)
from cortexdb import Cortex
c = Cortex.signup() # POST /v1/auth/signup → free-tier PASETO
c.experience(c.actor, text="hello, memory!", wait="indexed")
print(c.recall(c.actor, query="what did I say?")["context_block"])
Core operations
# Write — async by default (202); use wait="indexed" for read-your-writes.
c.experience("ws:demo", text="Deployed payments-service v3.1 to prod.", wait="indexed")
c.experience("ws:demo", text="Rolled back auth-gateway.", wait="indexed")
# Large content: wait="captured" returns at the durable accept point (~10ms);
# poll write_status to see indexing progress. After a client timeout, its
# `resend` says what to do: "no" means stored, and a write that failed indexing
# (status="failed") is stored too: never re-send it, at any age or under any
# key (a new key would store it twice); the operator re-includes it in place.
# "if_sent_within_24h" means no receipt: re-send if you sent it within a day.
c.experience("ws:demo", text=big_transcript, idempotency_key="k1", wait="captured")
c.write_status(idempotency_key="k1") # {found, status, stages_completed, resend, note}
# Preserve exact source structure for citations, replay, and artifact building.
from cortexdb import sha256_fingerprint
text = "Customer approved the renewal."
fingerprint = sha256_fingerprint(text)
c.experience(
"ws:demo",
text=text,
modality="conversation",
source={
"schema_version": 2,
"source_id": "crm:thread:42",
"modality": "conversation",
"content_kind": "message",
"source_fingerprint": fingerprint,
"fragments": [{
"fragment_id": "message-7",
"locator": {
"kind": "conversation",
"session_id": "thread-42",
"turn_index": 7,
},
"content_fingerprint": fingerprint,
}],
},
)
# Losslessly split a larger logical text source at UTF-8 boundaries. The
# response includes every fragment event ID; no existing one-event API changes.
source_write = c.experience_source_text(
"ws:demo",
handbook_text,
source_id="document:employee-handbook",
locator_path=["employee-handbook", "body"],
)
assert len(source_write["event_ids"]) == source_write["fragment_count"]
# The server retains replay keys for 24 hours. For longer jobs, persist each
# `on_fragment` checkpoint and pass it back through `completed_fragments`.
# Recall — returns a StratifiedPack { context_block, layers, provenance }.
pack = c.recall("ws:demo", query="recent deploys", view="holistic")
# Answer — recall + LLM render with citations.
ans = c.answer("ws:demo", "What changed in payments recently?")
# Forget — GDPR erasure. confirm_all wipes the whole scope.
c.forget("ws:demo", confirm_all=True, cascade="redact_events",
reason="GDPR Article 17 request")
# Layer reads (paginated { items, has_more }).
c.events("ws:demo")
c.facts("ws:demo")
c.beliefs("ws:demo")
c.episodes("ws:demo")
c.understanding("ws:demo")
Async usage
AsyncCortex has every method Cortex has, under the same name, with the same
parameters; each is a coroutine (streams and pages are async iterators):
import asyncio
import os
from cortexdb import AsyncCortex
async def main():
async with AsyncCortex(
"http://localhost:3141",
bearer=os.environ["CORTEX_API_KEY"],
) as c:
await c.experience(
"ws:demo",
text="async memory works great",
wait="indexed",
)
pack = await c.recall("ws:demo", query="what works?")
print(pack["context_block"])
asyncio.run(main())
Error handling
Every refusal raises the class of its error_code, from cortexdb.errors
(one per code: WAIT_TIMEOUT raises errors.WaitTimeoutError), which
subclasses the class of its HTTP status family: V1BadRequestError (400, and
V1ValidationError for 422), V1AuthError (401), V1PaymentRequiredError
(402), V1PolicyDeniedError (403), V1NotFoundError (404), V1ConflictError
(409, and V1PreconditionFailedError for 412), V1GoneError (410),
V1PayloadTooLargeError (413), V1RateLimitError (429),
V1ServerTimeoutError (408 and 504), V1ServerError (5xx) and
V1UnavailableError (503, with V1NotConfiguredError for a server missing a
provider). All of them are V1APIErrors, with status, error_code,
message, request_id, retriable (sending the same request again can
succeed), details, headers and retry_after (the Retry-After header in
seconds); each per-code class reads its documented details keys as
properties (err.waiting_on, err.limit, …). A code this SDK does not know
yet raises its family's class.
import os
import time
from cortexdb import Cortex, V1APIError, V1PolicyDeniedError, V1UnavailableError, errors
with Cortex("http://localhost:3141", bearer=os.environ["CORTEX_API_KEY"]) as c:
try:
c.experience("ws:demo", text="important data", wait="indexed")
except errors.WaitTimeoutError as e: # captured; the barrier ran out
print(f"still {e.waiting_on} after {e.waited_ms} ms")
except V1UnavailableError as e: # any 503
if e.retriable:
time.sleep(e.retry_after or 1)
except V1PolicyDeniedError as e: # any 403
print(f"refused: {e.error_code} {e.details}")
except V1APIError as e: # anything else the server refused
print(f"API error [{e.status} {e.error_code}]: {e.message}")
V1ConnectionError and V1TimeoutError (the client's own timeout) are the
transport's; V1InvalidRequestError is a request the SDK refused before
sending it, with the code the server would answer. A value a route always
refuses (an extra_scopes list past 40, a malformed break-glass header, a mint
the server never accepts) raises ValueError before anything is sent.
What's new in 0.13.0 for CortexDB 0.14.0
cortexdbai 0.13.0 covers the whole v1 API of CortexDB 0.14.0
(COVERAGE.md: every route and the method that calls it), on
both clients under the same names.
- Every route has a method, on
CortexandAsyncCortexalike: the access API (access_check,access_explain, the lookups,access_limits), AuthZEN 1.0, change sets (changeset_applyand its steps), the access watch and notices, applications, the directory, SCIM (client.scim), restores, source containers (rehome, watermarks, account resolution), rollups and publish, person, party and tenant erasures with receipts and proofs, the read-free forget, the operator's access routes, and the routes older releases left out (lifecycle, vocabularies, imports, export, keyed blobs, the cohort lanes, audit, retention, the code plane, the native memory host's). - Typed models for every request and response (
cortexdb.v1.models, TypedDicts generated from the server's OpenAPI contract). - A typed exception per error code (
cortexdb.errors), under one class per HTTP status family, withretriable,details(named properties for each documented key) andretry_after. - Consistency tokens:
consistency=Trueon writes and reads,consistency=<token>orat_least=<token>to demand one, andConsistencyTokensto carry them per root. - The new read and write fields:
extra_scopes(up to 40),coverage,beliefs="merged",mounts="default",purpose;provenance,intent,subjectand the envelope's other fields; streams asStreamEventiterators; a declared belief'sidempotency_key. - Paging and by-id reads: every listing pages with
pages()(events newest first by theirle1.cursor, the scope list, the layers);scope=on the by-id layer reads (a scope that does not hold the row is 404);include_vectors=Truereturns embeddings; the timeline takesas_of. - Auth: the actor follows the token,
mint_token's chain fields withmint_agent_tokenandmint_app_token, break-glass (client.break_glass) and the subject-token shadow (with_options(subject_token=...)).
Its breaking changes are below. Each feature has a section under Shared memory with an example the SDK's live tests run against a 0.14.0 server.
Breaking changes in 0.13.0
Upgrading from cortexdbai 0.12: every call keeps its name, its positional
parameters and every keyword it took; these behave differently.
- Errors are typed per code. Every refusal raises the class of its
error_code(cortexdb.errors), a subclass of its HTTP status family's class. A code this SDK does not know raises its family's class (a 503 is aV1UnavailableError), no longer a bareV1APIError: anexcept V1APIErrorkeeps catching everything, but atype(err) is V1APIErrorcheck no longer matches.V1NotConfiguredErroris now aV1UnavailableError, andV1InvalidRequestErroraV1BadRequestError. For a body withoutretriable,retriableis the code's documented default instead ofFalse. - The actor follows the token. A client built with a token bearer and no
actorsends the token'ssubasX-Cortex-Actor(0.12 sentuser:default, which the server refuses with 401ACTOR_MISMATCHfor any other subject), and no header for a token whose subject an issuer maps. The operator key and no bearer still senduser:default; passactorto choose. viewis the server's.recall,recall_stream,answerandcomposesendviewonly when you pass it (0.12 sent"holistic"). The server readsholisticas before, butgranularat an org tree's shared scope (team, org, group, compartment), whereholisticis 422VIEW_NOT_ALLOWED_AT_SCOPE.- Local refusals. A request the server always refuses fails before it is
sent, with
ValueError: anextra_scopeslist past 40, a malformed break-glass header, a mint whose chain fields breakmint_chain_v1's rules, more than 1,024provenanceids. - Server 0.14.0 refuses what 0.13.x served (its own release notes list
them): forget and erasure selectors are strict (422
INVALID_BODY, an unknown selector field or one the cascade would not apply,field_not_applied), every mint names its tenant root (scope,tenantorscopes; only the trusted operator mints deployment-wide, and only for aservice:orsystem:subject), mints are held to their lifetime ceilings, a token that carriesscopesmust confine every capability it uses, and a tenant indenyrefuses the operator key's data reads without break-glass. A header that does not parse is 400INVALID_HEADER(a malformed break-glass header wasBREAK_GLASS_INVALID, which has no class any more).
Shared memory (CortexDB 0.14.0)
CortexDB 0.14.0 adds org trees with real access control, and the routes that
run them: change sets to register an org's records, an access API that says who
may read what and why, AuthZEN for policy enforcement points, a watch of every
access change, notices, consistency tokens, applications, a directory of
people, SCIM, rollups, publish, and person and tenant erasures. Every method
below exists on Cortex (V1Client) and, with await, on AsyncCortex
(AsyncV1Client), under the same name.
The examples run, in order, against a fresh server (the SDK's live tests run
each of them). Start one with the operator key, the minter, and demo as the
deployment's tenant, so a token minted without tenant belongs to org:demo:
CORTEX_API_KEY=demo-operator-key CORTEX_V1_MINTER_ENABLE=1 \
CORTEX_V1_AUDIENCE=cortexdb:tenant:demo ./cortexdb
import os
from cortexdb import Cortex
URL = os.environ.get("CORTEX_URL", "http://localhost:3141")
ROOT = os.environ.get("CORTEX_DEMO_ROOT", "org:demo")
ENG = f"{ROOT}/team:eng"
# The deployment's operator key. `actor=None` sends no X-Cortex-Actor: the key
# then acts as itself.
operator = Cortex(URL, bearer=os.environ.get("CORTEX_API_KEY", "demo-operator-key"), actor=None)
Change sets: registering an org tree
An org's records (its org, teams, sets, people's private regions, sources …)
are written in change sets: staged, previewed (who gains and loses what), then
activated in one step. A compiler stages them under its own principal; here the
operator key acts as service:compiler. scope_record, member and
set_member build the version-2 records; a record holds no names (those live
in the directory, below).
from cortexdb.v1 import changeset_part, member, put_record, scope_record, set_member
compiler = operator.with_options(actor="service:compiler")
owner = member("service:compiler", "owner")
records = [
scope_record(ROOT, members=[owner, member("user:ada", "owner"), member("service:scim-okta", "owner")],
kind="org"),
scope_record(f"{ROOT}/set:eng", members=[owner], kind="set", parent=ROOT,
set={"entries": [{"actor": "user:alice"}, {"actor": "user:bob"}]}),
scope_record(ENG, members=[owner, set_member(f"{ROOT}/set:eng", "writer")],
kind="team", parent=ROOT, audience="members"),
scope_record(f"{ROOT}/user:alice", members=[member("user:alice", "owner"), owner],
kind="person", parent=ENG, private=True, self_principal="user:alice"),
]
# Stage, preview and activate in one call ...
applied = compiler.changeset_apply(ROOT, "org-compile-1", records, reason="first compile")
print(applied["activated"]["state"]) # activated
# ... or step by step, reading the preview before approving it.
sales = scope_record(f"{ROOT}/team:sales", members=[owner, member("user:carol", "writer")],
kind="team", parent=ROOT)
compiler.changeset_stage(ROOT, "sales-1", [changeset_part(0, [put_record(sales["path"], sales)])])
preview = compiler.changeset_preview("sales-1", ROOT, people=True)
print(preview["summary"]["people_gaining"], preview["widens"])
status = compiler.changeset_activate("sales-1", ROOT, preview["preview_hash"])
print(status["state"], status["consistency_token"][:4]) # activated ct1.
A refusal raises its typed error: PREVIEW_CHANGED (the registry moved since
the preview), ACCESS_ASSERTION_FAILED (a widening breaks one of the tenant's
policy cases), CHANGESET_STALE (a record changed after staging),
ACCESS_LIMIT_REACHED. changeset_get and changeset_discard read and drop a
staged change set.
Under a tenant whose operator mode is deny, the operator key's activation of
a change set that registers a record, names a service: principal or widens
who reads a private region is an in-band path. Its preview says so
(in_band, and in_band_parts names each record and why), and the
activation takes a break-glass client, else 403 BREAK_GLASS_REQUIRED:
compiler.break_glass("support", "SUP-1142").changeset_activate(id, ROOT, preview["preview_hash"]). Each private region it widens is told first (503
NOTICE_UNAVAILABLE, nothing activated, when that notice cannot commit), and
a registry change in between is 503 REGISTRY_STALE, which a retry decides
again. Staging, the preview and a discard need no header.
The access API
access_check answers whether a principal chain may read, write or administer
a scope, or read one memory, with the deciding path of every link;
access_explain says why, atom by atom. Without chain, it decides for the
caller's own token; another chain may be asked about by an Owner of the scope
or the trusted operator. access_lookup_scopes lists what a chain may read
under a prefix, access_lookup_principals who may read a scope, and
access_limits the limits the deployment enforces (read them; never hard-code
them).
check = operator.access_check(ENG, role="writer", chain={"sub": "user:alice"})
print(check["allowed"], check["delivered_role"]) # True writer
denied = operator.access_check(ENG, chain={"sub": "user:mallory"})
print(denied["denied_by"], denied["links"]["person"]["reason"]) # person not_a_member
why = operator.access_explain(ENG, chain={"sub": "user:alice"})
print(why["home"]["links"]["person"]["via"][0]["record"]) # org:demo/team:eng
for page in operator.pages(operator.access_lookup_scopes, ROOT, chain={"sub": "user:alice"}):
for item in page["items"]:
print(item["scope"], item["role"]) # org:demo/team:eng writer
readers = operator.access_lookup_principals(ENG)
print(sorted(i.get("principal") or i.get("set") for i in readers["items"]))
limits = {limit["name"]: limit["value"] for limit in operator.access_limits(ROOT)["limits"]}
print(limits["apps"], limits["scope_path_segments"]) # 8 32
AuthZEN 1.0
A third-party policy enforcement point asks the same questions through AuthZEN
1.0: authzen_evaluation (actions summary, read, write, own) and
authzen_search_resource, with the PDP's metadata at
authzen_configuration().
print(operator.authzen_evaluation("user:alice", "write", ENG)) # {'decision': True}
print(operator.authzen_evaluation("user:mallory", "read", ENG))
# {'context': {'denied_by': 'person'}, 'decision': False}
found = operator.authzen_search_resource("user:alice", "read", ROOT, limit=10)
print([r["id"] for r in found["results"]])
print(operator.authzen_configuration()["access_evaluation_endpoint"])
Watch and notices
access_watch follows a root's access changes (registry changes with their
consistency tokens, change-set events, notices, assertion breaks and
restores) from a position, within a 7-day window; wait_ms long-polls. A
person reads the notices that name them (who read what of theirs, under a
grant or a break-glass read) with access_notices and acknowledges them with
access_notices_ack.
start = operator.access_watch(ROOT) # the current position, no history
ops = scope_record(f"{ROOT}/team:ops", members=[owner], kind="team", parent=ROOT)
compiler.changeset_apply(ROOT, "ops-1", [ops])
page = operator.access_watch(ROOT, after=start["position"], wait_ms=2000)
print(sorted({item["type"] for item in page["items"]})) # ['changeset', 'registry']
alice = Cortex(URL, bearer=operator.mint_token(subject="user:alice", scope=ROOT)["token"])
notices = alice.access_notices(ROOT)
if notices["items"]:
alice.access_notices_ack(ROOT, seqs=[n["seq"] for n in notices["items"]])
Consistency tokens
Every write and read can carry a consistency token: a lower bound, so a later
read that demands it sees at least what that response saw or wrote, on any
node. Pass consistency=True to a write (or a read) for a token, then
consistency=<token> to a read, or at_least=<token> to a GET read. A
ConsistencyTokens tracker does it for you, per scope root.
from cortexdb.v1 import ConsistencyTokens
receipt = alice.experience(ENG, text="Shipped payments v3.1 today", consistency=True)
pack = alice.recall(ENG, query="what shipped?", consistency=receipt["consistency_token"])
events = alice.events(ENG, at_least=pack["consistency_token"])
tokens = ConsistencyTokens()
session = alice.with_options(consistency=tokens)
session.experience(ENG, text="Rolled back auth-gateway", wait="captured")
pack = session.recall(ENG, query="what was rolled back?") # waits for that write
print(sorted(tokens.as_dict())) # ['org:demo']
A demand the node cannot reach in time answers 408 WAIT_TIMEOUT
(details.waiting_on: registry, indexer or access_view); a token of
another root, or one this deployment never issued, 422
CONSISTENCY_TOKEN_INVALID.
Applications
An application record (<root>/app:<id>) names what an application writes and
what its chains read by default: mounts, which mounts="default" on a read
adds (each admitted by an apps entry of the record it mounts). A tenant
holds at most limits.apps of them (access_limits). The record holds no
name: app_put refuses display_name, and the application's name is the
directory's name entry of its path (directory_put(f"{ROOT}/app:notes", ROOT, display_name="Notes")).
ada = Cortex(URL, bearer=operator.mint_token(subject="user:ada", scope=ROOT)["token"])
app = ada.app_put("notes", ROOT, kinds=["note", "decision"])
print(app["path"], app["home"]) # org:demo/app:notes org:demo/app:notes/user:{person}
print(ada.app_get("notes", ROOT)["kinds"])
The directory
Each tenant's people, with their names, emails and source accounts, and the name entries of its scopes and sets. Records hold no names: a scope's display name is its entry here. Writes can be conditional on the version last read.
operator.directory_put("user:alice", ROOT, display_name="Alice Moreau", emails=["Alice@Demo.Example"],
accounts=[{"system": "slack:T1", "account": "UALICE"}])
operator.directory_put(ENG, ROOT, display_name="Engineering") # a scope's name entry
entry = operator.directory_get("user:alice", ROOT)
print(entry["emails"], entry["version"]) # ['alice@demo.example'] 1
# A put replaces the whole entry; if_version makes it conditional on the version read.
operator.directory_put("user:alice", ROOT, display_name="Alice M.", emails=entry["emails"],
accounts=entry["accounts"], if_version=entry["version"])
print([e["id"] for e in operator.directory_search(ROOT, "ali")["items"]])
resolved = operator.access_resolve_accounts(ROOT, [("slack:T1", "UALICE"), ("slack:T1", "UNKNOWN")])
print([a["id"] for a in resolved["accounts"]]) # ['user:alice', None]
A stale version is 412 DIRECTORY_VERSION_CHANGED; an account another
principal holds 409 DIRECTORY_ACCOUNT_TAKEN. A deactivated entry
(status="deactivated") refuses every credential of the person, 401
SUBJECT_UNMAPPED, until it is active again.
SCIM
An identity provider provisions the tenant's people and groups through SCIM
2.0, with a bearer for service:scim-<client> that is an Owner of the tenant
root. client.scim sends its requests the way a provider does, without
X-Cortex-Actor; scim_user, scim_group and scim_op build the resources.
from cortexdb.v1 import scim_group, scim_op, scim_user
idp = Cortex(URL, bearer=operator.mint_token(subject="service:scim-okta", scope=ROOT,
ttl_seconds=30 * 86400)["token"])
faye = idp.scim.create_user(scim_user("faye@demo.example", external_id="00u-faye", display_name="Faye Ng"))
group = idp.scim.create_group(scim_group("Platform", [faye["id"]]))
print(idp.scim.list_users(filter='userName eq "faye@demo.example"')["totalResults"]) # 1
print(idp.scim.resource_type("User")["endpoint"]) # /Users
idp.scim.patch_user(faye["id"], [scim_op("replace", value={"active": False})]) # her tokens stop
idp.scim.patch_user(faye["id"], [scim_op("replace", value={"active": True})]) # and work again
idp.scim.delete_group(group["id"])
idp.scim.delete_user(faye["id"])
SCIM's error bodies raise the SDK's typed errors, with the CortexDB code as
error_code and SCIM's scimType as scim_type.
Restores
A copy restored with its access state lost (cortexdb restore-check --access-state lost) holds every root it restored pending: requests that do
not repair it answer 503 ACCESS_RESTORE_PENDING (not retriable), until each
of the root's access sources re-sends its state (change sets staged with
resync=True, the last one resync=True and activated) and acknowledges with
access_restore_ack, or the trusted operator overrides with
admin_access_restore. access_records pages a root's registry records, what
a source diffs its own state against, with the root's consistency token
(at_least= waits for one). access_texts_erase(root, path) erases the free
texts a record names (reasons, a hold's matter, a policy case's notes) from
every voter's files; the record keeps its references.
from cortexdb.v1 import V1APIError
records = operator.access_records(ROOT)
print(len(records["items"]) > 0, records["revision_changed"]) # True False
print(operator.access_records(ROOT, at_least=records["consistency_token"])["items"] != []) # True
print(compiler.access_texts_erase(ROOT, ENG)["erased"] >= 0) # True
try:
operator.access_restore_ack(ROOT)
except V1APIError as error:
print(error.error_code) # RESTORE_NOT_PENDING
Source containers
A connector writes each item into its permission unit's container
(<tenant>/source:<system>/container:<key>), registered through change sets.
When an item's sharing changes it moves the item's live events with
access_rehome (nothing is erased); after each sync it stores the source's
watermark (access_watermark_put), which reads report in coverage.
from datetime import datetime, timedelta, timezone
slack_owner = member("service:slack", "owner")
source = f"{ROOT}/source:slack"
general, launch = f"{source}/container:general", f"{source}/container:launch"
compiler.changeset_apply(ROOT, "slack-1", [
scope_record(source, members=[slack_owner], kind="source", parent=ROOT),
scope_record(f"{ROOT}/set:slack-members", members=[slack_owner], kind="set", parent=ROOT,
set={"entries": [{"actor": "user:alice"}]}),
scope_record(general, members=[slack_owner, set_member(f"{ROOT}/set:slack-members", "reader")],
kind="container", parent=source),
scope_record(launch, members=[slack_owner, set_member(f"{ROOT}/set:slack-members", "reader")],
kind="container", parent=source, private=True),
])
slack = Cortex(URL, bearer=operator.mint_token(subject="service:slack", scope=ROOT)["token"])
synced = (datetime.now(timezone.utc) - timedelta(seconds=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
print(slack.access_watermark_put(source, synced)["changed"]) # True
message = slack.experience(general, text="Launch moves to Friday", wait="indexed")
print(slack.access_rehome(launch, [message["event_id"]])["moved"]) # 1
print([w["source"] for w in alice.access_watermarks(launch)["watermarks"]])
Rollups and publish
A unit with an aud:roll compartment gets rollups: typed totals per month and
synthesis items, built by the shard leader and served to a reader only when it
reads every input (dominance), so its head reads them and others do not. A
person widens a memory only by publishing a copy of it into a home they write;
erasing the original erases the copy.
import time
roll = f"{ENG}/aud:roll"
compiler.changeset_apply(ROOT, "eng-rollup-1", [
scope_record(roll, members=[owner], kind="compartment", parent=ENG, audience="rollup"),
scope_record(f"{ENG}/attrs:unit", kind="attrs", parent=ENG, attrs={"heads": [{"actor": "user:ada"}]}),
])
alice.experience(ENG, text="Decided: ship the new pricing page", wait="indexed")
for _ in range(60): # a unit is built the first time it is read
rolled = ada.rollups(roll)
if rolled["items"]:
break
time.sleep(1)
print(rolled["items"][0]["kind"], ada.rollup_totals(roll)["totals"]["events"] > 0)
note = alice.experience(f"{ROOT}/user:alice", text="Proposal: weekly design reviews", wait="indexed")
copy = alice.publish(ENG, f"{ROOT}/user:alice", event_id=note["event_id"], wait="indexed")
print(copy["published_from"]["event_id"] == note["event_id"]) # True
Only a person's own chain publishes, through an application the person
drives too (app with client: "person"): an agent's act, a run, a service,
an app without client and the operator key are 403
PUBLISH_PERSON_REQUIRED. consistency=True returns a token at the target's
root. Publishing a norm
(intent="procedure", rule, …) needs a sign-in within 10 minutes (401
FRESH_AUTH_REQUIRED, its RFC 9470 challenge in the error's headers).
Reading across scopes
A read names its home scope and up to 40 extra_scopes under the same
tenant root, and searches them all as one read set; coverage=True reports
what it searched, what of its read set the caller may read, and each gap with
its reason (a scope left out by a bound, a deadline, a source's watermark, a
scope the caller may not read …), so a response never reads as complete when
it is not. beliefs="merged" rebuilds the beliefs of every scope read into
one set, and mounts="default" adds an application chain's mounts (a token
minted with app). purpose declares what the read is for
([a-z0-9_-]{1,64}), which an access path's when: {purpose} condition reads;
a read refused only for want of the purpose its one path names raises
errors.PolicyDeniedError with reason == "purpose_required" and purpose
the one it needs. recall, recall_stream, answer and compose take the
same fields, and access_check and access_explain take purpose.
pack = alice.recall(ENG, query="when does the launch move?", extra_scopes=[general, launch],
coverage=True)
print(sorted(pack["coverage"]["searched"]["scopes"]))
# ['org:demo/source:slack/container:general', 'org:demo/source:slack/container:launch',
# 'org:demo/team:eng']
print({event["scope"] for event in pack["layers"]["events"]} >= {launch}) # True
answer = alice.answer(ENG, "When does the launch move?", extra_scopes=[general, launch],
beliefs="merged", coverage=True, purpose="planning")
print([citation["id"] for citation in answer["citations"]][:1])
A scope outside the root, or a list past the bound, is 422
INVALID_EXTRA_SCOPES; mounts without an application in the token 422
INVALID_MOUNTS (details.reason no_app).
Streams
recall_stream and answer_stream yield StreamEvents (event, data,
id) as the server sends them: a recall's plan, each layer, its
context_block and done (with the consistency token); an answer's tokens,
citations and done. lifecycle_stream follows the write lifecycle
(captured, indexed …) of a scope, an event or a job. An error event
raises its typed error. On AsyncCortex they are async iterators.
names = [event.event for event in alice.recall_stream(ENG, query="what shipped?")]
print(names[0], names[-1]) # plan done
text = "".join(event.data["text"] for event in alice.answer_stream(ENG, "What shipped?")
if event.event == "token")
print(bool(text)) # True
Writes that cite their sources
A write that summarizes or derives from other memories names them in
provenance (at most 1,024 event ids in the same root): the stored event
keeps them, and reads and erasures follow them. intent, subject,
observed_actor, directives and context are the envelope's other
fields.
notes = alice.experience(ENG, text="Raw notes: the gateway timed out twice", wait="indexed")
summary = alice.experience(ENG, text="Summary: gateway timeouts after the deploy", wait="indexed",
provenance=[notes["event_id"]], intent="decision")
stored = alice.event(summary["event_id"], scope=ENG)
print(stored["provenance"] == [notes["event_id"]], stored["context"]["intent"]) # True decision
An id that names no event of the root is 422 PROVENANCE_UNKNOWN
(details.cited), an erased one 409 PROVENANCE_ERASED, one in another root
422 CROSS_ROOT_PROVENANCE.
The read-free forget
A deletion worker forgets by keys it already holds, so the server reads
nothing first: idempotency_keys (the keys the writes were sent with; for
another principal's write, (key, caller)), and capture_keys (a label the
writer put on every event of one capture). forget_preview counts the same
selector. It takes Owner at the scope; the default derived_only cascade
removes the selected events with what was derived from them. What a legal hold
keeps is matched, not deleted: matched counts it (at most requested), and
events_under_legal_hold and derived_under_legal_hold count it apart, in
neither deleted nor estimated_deleted.
home = f"{ROOT}/user:alice"
alice.experience(home, text="A draft I will take back", idempotency_key="draft-1", wait="indexed")
preview = alice.forget_preview(home, idempotency_keys=["draft-1"])
print(preview["requested"], preview["matched"], preview["events_under_legal_hold"]) # 1 1 0
gone = alice.forget(home, idempotency_keys=["draft-1"], from_preview_id=preview["preview_id"])
print(gone["deleted"]["events"]) # 1
for part in ("first", "second"):
alice.experience(home, text=f"call 42, {part} passage", labels=["cap:call-42"], wait="indexed")
print(alice.forget(home, capture_keys=["cap:call-42"])["deleted"]["events"]) # 2
Erasures
erase deletes the rows themselves (not only what was derived), frees the
writes' idempotency keys and leaves a durable receipt that erasure_verify
proves store by store; erasure_wait polls a job (with residual=True, until
its bytes left the log on every voter). The trusted operator also erases an
outside party by address (subject_email: the events writers labelled
party:<address>, in every scope of the tenant), a person (erase_person:
their region, their application and capture homes, their own messages in the
tenant's groups, then their directory entry) and a whole tenant
(erase_tenant, a dry run unless dry_run=False and confirm=True). A legal
hold keeps what it names.
slack.experience(general, text="Pat from Initech asked for a quote", wait="indexed",
labels=["party:pat@initech.example"])
preview = operator.erasure_preview(ROOT, subject_email="pat@initech.example")
print(preview["estimated_affected"]["events"]) # 1
job = operator.erase(ROOT, subject_email="pat@initech.example", confirm=True,
from_preview_id=preview["preview_id"])
receipt = operator.erasure_receipt(job["erasure_id"])
print(receipt["receipt"]["kind"], receipt["sha256_verified"]) # party True
print(operator.erasure_verify(job["erasure_id"])["checks_ok"]) # True
leaver = operator.erase_person(ROOT, "user:bob", confirm=True, reason="leaver")
print(operator.erasure_wait(leaver["erasure_id"])["status"]) # completed
plan = operator.erase_tenant(ROOT) # counts only
print(plan["dry_run"], plan["counts"]["records"] > 0) # True True
Operator routes
The trusted operator reads and sets each tenant's operator mode
(operator_data_reads: allow, audit, which tells the people whose
memories an operator read touched, or deny, which takes break-glass) beside
the deployment's own (deployment_mode, what a tenant that clears its mode
falls back to), reads the access report's counters, rebinds a root's records
from the static key's tenant, and reads the node's health, readiness and
metrics.
settings = operator.admin_tenant_settings(ROOT)
print(settings["operator_data_reads"], settings["deployment_mode"])
# {'mode': 'allow', 'source': 'deployment'} allow
operator.admin_set_tenant_settings(ROOT, operator_data_reads="audit")
report = operator.admin_access_report(ROOT)
print(report["scope"], len(report["checks"]) > 0) # org:demo True
operator.admin_set_tenant_settings(ROOT, operator_data_reads=None) # the deployment's mode again
print(operator.ready()["ready"], "disk" in operator.admin_metrics()) # True True
Tokens and principal chains
A client acts as its token's subject. mint_token mints a PASETO token for a
principal; its chain fields say who is acting for whom: act (an agent acting
for the subject; it writes only with a run), app and client (an
application, and whether a person or an agent drives it), run and
run_start, and taint tags. mint_agent_token and mint_app_token mint
the two common chains. A token with a chain field lives at most a day
(CHAIN_TOKEN_MAX_TTL_SECONDS), and an agent chain token names its caps
with scopes that confine each; the SDK refuses before sending what the
server always refuses (check_mint, a ValueError). Other tokens live at
most lifetime_ceiling(subject): a week for a person or an agent, 30 days for
a service. with_token derives a client that uses another token over the
same connections.
from cortexdb.v1 import lifetime_ceiling
erin = Cortex(URL, bearer=operator.mint_token(subject="user:erin", scope=ROOT)["token"])
print(erin.actor, lifetime_ceiling("user:erin")) # user:erin 604800
home = f"{ROOT}/user:alice"
planner = alice.with_token(alice.mint_agent_token(
"user:alice", "agent:planner", run="weekly-plan-1", ttl_seconds=600,
caps=["scope.write", "scope.read.local"], scopes=[f"scope.write:{home}!", f"scope.read.local:{home}!"]))
planner.experience(home, text="Drafted the weekly plan", wait="indexed")
chain = planner.whoami()["chain"]
print(chain["act"], chain["run"]) # agent:planner weekly-plan-1
# The application's own principal mints a person's token for it.
notes = alice.with_token(operator.with_options(actor="service:notes").mint_app_token(
"user:alice", "service:notes", scope=ROOT))
print(notes.whoami()["chain"]["app"]) # service:notes
act naming an AI employee is 422 INVALID_BODY (act_not_delegable: an AI
employee's token has the employee as sub and no act); a token over its
ceiling 422 INVALID_BODY ttl_too_long at the mint and 401
INVALID_TOKEN_CLAIM (lifetime_over_ceiling) once presented. Each caps
entry is one capability: a pattern such as scope.* is 422 INVALID_BODY
with field == "caps", entry the entry and reason == "wildcard_not_allowed" ("invalid" for an entry that does not parse).
Break-glass and the subject-token shadow
A tenant's operator mode (admin_set_tenant_settings) decides what the
operator key may do with the tenant's data: allow, audit (served and
counted), or deny, where reads of stored rows, private regions and the
in-band paths answer 403 BREAK_GLASS_REQUIRED unless the request carries
X-Cortex-Break-Glass. break_glass(reason, ticket, note) derives a client
that sends it: every request is audited with the reason and ticket, and a read
of a private region tells its person first. The header is checked here as the
server checks it. with_options(subject_token=...) sends the person's own
token beside the key (X-Cortex-Subject-Token): the server evaluates each
decision for that token too and counts the differences in the access report,
without changing any response.
from cortexdb import errors
operator.admin_set_tenant_settings(ROOT, operator_data_reads="deny")
try:
operator.events(f"{ROOT}/user:alice")
except errors.BreakGlassRequiredError as refused:
print(refused.error_code, refused.mode) # BREAK_GLASS_REQUIRED deny
glass = operator.break_glass("support", "SUP-1142", "alice asked for help with her notes")
print(len(glass.events(f"{ROOT}/user:alice")["items"]) > 0) # True: served and audited
print(alice.access_notices(ROOT)["items"][-1]["cause"]) # break_glass
glass.admin_set_tenant_settings(ROOT, operator_data_reads=None) # lowering from deny takes it too
shadowed = operator.with_options(subject_token=alice.bearer)
shadowed.recall(ENG, query="what shipped?")
checks = {c["check"]: c["requests"] for c in operator.admin_access_report(ROOT)["checks"]}
print(checks["subject_shadow"] > 0) # True
Integrations
Framework and provider adapters ship as optional extras of the same
cortexdbai package — there are no separate cortexdb-<framework> packages
to install. Pick an extra and import from cortexdb.integrations.<submodule>:
pip install cortexdbai[langgraph] # or crewai, llamaindex, autogen, …
from cortexdb.integrations.langgraph import CortexDBCheckpointer, cortexdb_memory_node
Available extras (pip install cortexdbai[<extra>]):
| Extra | Import submodule |
|---|---|
autogen |
cortexdb.integrations.autogen |
beeai |
cortexdb.integrations.beeai |
camel |
cortexdb.integrations.camel |
controlflow |
cortexdb.integrations.controlflow |
crewai |
cortexdb.integrations.crewai |
dify |
cortexdb.integrations.dify |
dspy |
cortexdb.integrations.dspy |
flowise |
cortexdb.integrations.flowise |
google-adk |
cortexdb.integrations.google_adk |
hermes |
cortexdb.integrations.hermes |
instructor |
cortexdb.integrations.instructor |
langgraph |
cortexdb.integrations.langgraph |
letta |
cortexdb.integrations.letta |
llamaindex |
cortexdb.integrations.llamaindex |
nemo-guardrails |
cortexdb.integrations.nemo_guardrails |
openai-agents |
cortexdb.integrations.openai_agents |
prefect |
cortexdb.integrations.prefect |
pydanticai |
cortexdb.integrations.pydanticai |
smolagents |
cortexdb.integrations.smolagents |
temporal |
cortexdb.integrations.temporal |
langchain |
cortexdb.integrations.langchain |
ag2 |
cortexdb.integrations.ag2 |
agno |
cortexdb.integrations.agno |
groq |
cortexdb.integrations.groq |
fireworks |
cortexdb.integrations.fireworks |
together |
cortexdb.integrations.together |
deepinfra |
cortexdb.integrations.deepinfra |
ollama |
cortexdb.integrations.ollama |
vllm |
cortexdb.integrations.vllm |
Constructor
| Parameter | Type | Default | Description |
|---|---|---|---|
api_url |
str |
http://localhost:3141 |
CortexDB v1 surface URL |
actor |
str | None |
the token's sub |
Sent as X-Cortex-Actor: by default the bearer token's sub (no header for a subject an issuer maps), user:default for the operator key or no bearer; None sends none |
bearer |
str | None |
None |
PASETO v4 public / JWT bearer, or the operator key (omit for auth-disabled dev) |
timeout |
float |
60.0 |
Request timeout (seconds) |
headers |
dict | None |
None |
Extra headers on every request |
consistency |
ConsistencyTokens | None |
None |
A tracker every request feeds and demands from (read-your-writes across nodes) |
with_options(actor=, headers=, timeout=, consistency=, bearer=, break_glass=, subject_token=) derives a client that shares the connection pool;
break_glass(reason, ticket, note) and with_token(token) are shorthands.
Links
License
Apache-2.0
Metadata
Release files for cortexdbai 0.13.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| cortexdbai-0.13.0.tar.gz | 446.0 kB | Without provenance | Details for cortexdbai-0.13.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| cortexdbai-0.13.0-py3-none-any.whl | Python 3 | none | any | Details for cortexdbai-0.13.0-py3-none-any.whl |
Total release size: 832.4 kB
Release files / cortexdbai-0.13.0.tar.gz
| Download URL | cortexdbai-0.13.0.tar.gz |
|---|---|
| Size | 446.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bcbbdc2deec0e0e428e324f3362034bdf5a766f1982c935a00062bfad40c1a66
|
|
BLAKE2b-256 checksum How to use checksums |
c754905b755dedb35a18e5265ad389fa1245b9555cbb9f8e555baa4ea4d2fdbb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.9
|
Release files / cortexdbai-0.13.0-py3-none-any.whl
| Download URL | cortexdbai-0.13.0-py3-none-any.whl |
|---|---|
| Size | 386.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
141fe859066a6d8df4485211291cf792659813da5a09901045c626644d7b9de2
|
|
BLAKE2b-256 checksum How to use checksums |
b78febec51d2a8385b866310246da88fe83689ddcf9710680a0314aeee3c913c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.9
|
Security for cortexdbai 0.13.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.