Tex Python SDK
SuperMemory-compatible memory SDK for Python.
Installation
pip install tex-sdk
Optional HTTP/2 support:
pip install "tex-sdk[http2]"
Requires Python 3.9+.
Quick Start
API key
from tex import Tex
client = Tex(api_key="sk-...", base_url="http://localhost:8000")
client.add("The quick brown fox jumps over the lazy dog.")
results = client.search.documents("fox")
Environment variables
Set TEX_API_KEY and TEX_BASE_URL, then:
from tex import Tex
client = Tex()
Per-user clients (production)
To act as one end user of your app, pass that user's id alongside your API key.
The SDK makes a single POST /auth/token-exchange with
{api_key, user_id, session_id} and receives a JWT minted for that user:
import os
from tex import Tex
# base_url falls back to TEX_BASE_URL, api_key to TEX_API_KEY
alice = Tex(
api_key=os.environ["TEX_API_KEY"],
base_url=os.environ["TEX_BASE_URL"],
org_id="org1", # optional; if set, must match the key's org
user_id="alice",
session_id="chat-42", # optional
)
alice.recall(q="what did I order last week?", session_id="chat-42") # every call is scoped to alice
- The API key must carry an impersonation-capable scope, normally
impersonate_user(also accepted:tenant:write:any_user,agent,service,system,*). Without one the backend refuses theuser_idand the SDK raisesPermissionDeniedErrorsaying so. - The minted token carries an
actclaim naming the key: it is locked to that user and cannot impersonate anyone else. The SDK also checks the returned token is really for the requested user. - On expiry the SDK uses the refresh token, and otherwise re-exchanges for the
same
user_id. - The per-user token is sent on every call, including the
Supermemory-compatible ones (
memories.add,documents.add,search.memories,profile, ...), so they also act as that user with that user's team scopes.container_tagon such a client must be omitted or equal the user id; naming another user is refused with 403container_tag_not_permitted. (Requires a backend that accepts per-user tokens on/v3and/v4.) - Omit
user_idto act as the key's own identity; compat calls then send the raw API key andcontainer_tagselects the end user, Supermemory-style.
Org + user login (development servers only)
from tex import Tex
client = Tex(base_url="http://localhost:8000", org_id="org1", user_id="user1")
With no API key the SDK calls POST /auth/login, which exists only when the
backend runs with DEBUG=true. Production returns 403 and the SDK raises
PermissionDeniedError pointing you to the per-user API key pattern above.
Agent memory: remember and recall
The path for an agent's conversational memory. remember stores turns, recall reads them back,
forget retires one.
turns = [
{"role": "user", "text": "I moved to Lisbon last month.", "timestamp": "2026-10-14T09:00:00Z"},
{"role": "assistant", "text": "Noted - Lisbon it is.", "timestamp": "2026-10-14T09:00:05Z"},
]
saved = client.conversations.remember(turns, session_id="chat-42")
print(saved.turn_ids) # one durable id per stored turn
result = client.recall("Where does the user live?", session_id="chat-42")
for hit in result.hits.turns + result.hits.observations:
print(hit.score, hit.text)
# Deeper long-term search: documents and the knowledge graph too (see below)
result = client.recall("Where does the user live?", session_id="chat-42", mode="deep")
# Retire one turn, by an id from saved.turn_ids
gone = client.conversations.forget(saved.turn_ids[0])
print(gone.forgotten, gone.residue) # residue: pieces that outlive it once consolidated
- A turn is
{"role", "text", "timestamp"}, all three required.timestampis ISO 8601, epoch seconds/ms, or natural language ("yesterday 3pm","2 hours ago"), read intimezone=(an IANA name such as"Europe/Berlin", or"+05:30"; default UTC).saved.turn_timessays how each one was read:sourceisexplicit,relativeorarrival(not understood: the arrival time,uncertain=True). Recall hits carry the same ashit.timestamp/hit.timestamp_ms/hit.time_uncertain. An optionalobservationslist attaches atomic facts to the turn. recalltakesmin_score=, a floor on each hit'sscore(the cosine between your query and the memory): without it you always get thetop_knearest hits, however weak.
Probable answer
Sometimes memory can give the most probable answer: when, by sense - the time or context the question
is about - one memory is more probable than the others. recall (both modes) then sets
result.probable_answer; the list is still returned as before.
result = client.recall("What did the user drink before switching?", session_id="chat-42")
pa = result.probable_answer
if pa is not None and pa.is_clear(0.7): # 0.7 is your app's own threshold
print(pa.text, pa.probability, pa.question_time) # question_time: current|past|date|ever|future|timeless
print([(alt.id, alt.probability) for alt in pa.alternatives])
else:
print(result.probable_answer_reason) # None, no_memory_answers, unavailable or not_enabled
# Skip it for one call
result = client.recall("Where does the user live?", session_id="chat-42", probable_answer=False)
probable_answeris always one of the hits (sameid,text,timestamp);probabilityis 0..1.- When it is
None,probable_answer_reasonsays why:no_memory_answers(no memory in the list answers the question),unavailable(it could not be worked out this time) ornot_enabled.
Which tier recall reads
recall() defaults to mode="active": short-term active memory plus one search over the facts
already consolidated into long-term memory. When a session has been idle for its inactivity window
(30 minutes by default; see Active memory inactivity window) it is
consolidated and leaves the active tier, and a default recall() still finds those facts.
mode="deep" searches the rest of long-term memory as well (documents, knowledge-graph statements) and
is slower.
Documents
Add, retrieve, update, delete, list, and batch-manage documents.
# Top-level shortcut
resp = client.add("Document content", container_tag="notes")
# Equivalent resource call
resp = client.documents.add("Document content", container_tag="notes")
# add() returns the document id (and the ingestion job id)
print(resp.id, resp.status, resp.job_id)
# document_date: when the content is FROM (YYYY-MM-DD or ISO 8601), not when it was uploaded.
# documents.update and documents.batch_add take it too.
client.documents.add("Minutes of the March offsite", container_tag="notes", document_date="2026-03-01")
# Get by ID. A document belongs to its container (user): pass the same
# container_tag you added it with when acting for a container with an API key.
doc = client.documents.get(resp.id, container_tag="notes")
# Update: metadata/custom_id in place; new content replaces the document
# under the same id (re-ingested). Owner-only; 409 while it is still processing.
client.documents.update(resp.id, content="Updated content", container_tag="notes")
# Delete the document and everything derived from it. Owner-only.
client.documents.delete(resp.id, container_tag="notes")
# List with filters
docs = client.documents.list(container_tags=["notes"], limit=20)
# Batch add
client.documents.batch_add(
["First document", "Second document"],
container_tag="batch-import",
)
# Bulk delete: by ids, or every document of a container (documents only —
# memories and conversations are untouched). An empty filter is refused.
out = client.documents.delete_bulk(container_tags=["old-tag"])
for err in out.errors: # each document NOT deleted, with the reason
print(err.id, err.error)
A document lives in the container it was added to. documents.get, documents.update and
documents.delete must be given the same container_tag the document was added with; without it the
server looks in the caller's own container and answers 404.
Search
Document search
results = client.search.documents("quarterly revenue", limit=10)
# Alias
results = client.search.execute("quarterly revenue", limit=10)
Memory search
results = client.search.memories("user preferences", container_tag="user1", threshold=0.3)
threshold is a cut-off on each hit's similarity: the cosine between your query and the memory, the
same number every result reports. The server's default is 0.3; raise it for fewer, closer hits. For an agent's conversational memory, recall (below) is the primary read path.
Memories
# Add memories
client.memories.add(
[{"content": "User prefers dark mode"}],
container_tag="prefs",
)
# A memory longer than one embedding (8191 tokens) is stored as a document
result = client.memories.add([{"content": "A very long design note ..."}], container_tag="prefs")
for m in result.stored_as_document: # empty for ordinary memories
print(m.document_id, m.job_id) # m.id is the document id
print(result.message) # why, in words (None when nothing was redirected)
# Forget a memory
client.memories.forget("prefs", id="mem_abc123")
# Update a memory
client.memories.update_memory("prefs", "User prefers light mode", id="mem_abc123")
Profile
profile = client.profile("user-container-tag")
print(profile.profile)
Tex-Unique Features
NLQ (Natural Language Query)
result = client.nlq.execute("What meetings did I have last week?")
print(result.text)
print(result.evidence)
Episodes
# Store a conversation
client.episodes.store(
[
{"role": "user", "content": "Book a table for two"},
{"role": "assistant", "content": "Which restaurant?"},
],
episode_id="ep_001",
)
# List episodes
episodes = client.episodes.list(limit=10)
episodes.store stores each message the same way conversations.remember stores a turn. The messages can
be recalled at once, and the episode appears in episodes.list after it is consolidated. Each message takes
content (or text), role, and an optional timestamp (same forms as a turn's, natural language read in
timezone=). If you leave out the timestamp, the arrival time is used. The result is the server's JSON as a
dict with typed accessors: .episode_id (yours, else your session, else a new id), .turn_ids (pass one to
conversations.forget), .turn_times and .usage.
Preferences
job = client.preferences.store([
{
"category": "beverage",
"entity": "espresso",
"entity_type": "Drink",
"preference_type": "PREFERS", # or "DISLIKES"
"confidence": "high", # "high" | "medium" (default) | "low"
"context": "first thing in the morning",
},
])
print(job["job_id"], job["status"])
category, entity, entity_type and preference_type are required; context, confidence, source
and timestamp are optional. Anything else (a numeric confidence, an unknown key) raises
InvalidPreferenceError before any request: the server would reject it or silently drop it. Storing
starts an ingestion job; client.jobs.get(job["job_id"]) reports its progress.
DB (developer keys only)
Raw HelixQL access to the graph engine. It needs an API key with the developer (or admin) role, on a
client built without user_id=. Any other key, and every per-user token, gets 403
Requires one of roles: admin, developer. Product code should use recall, search and nlq instead.
schema = client.db.schema() # the node/edge labels your graph has
result = client.db.query("QUERY GetPeople() => people <- N<Person> RETURN people") # HelixQL, not Cypher
print(result["success"], result.get("data"))
Auth
info = client.auth.whoami()
Jobs
status = client.jobs.get("job_abc123")
Scope memberships
A user always sees their own user:<id> scope and their org:<org> scope — those are implicit. Every
shared team: or project: space is an explicit grant, and client.scopes lets your own org admin
manage those grants instead of asking us to.
Same key as deletion: a non-impersonated org-admin API key (role admin or *, on a client built
without user_id=). A per-user token inherits the key's roles and is refused, so it can never grant
itself a scope. The org is always the token's — cross-org grants are impossible here.
grant = client.scopes.grant("alice", "team:eng") # role="member" by default
print(grant.created) # False on an idempotent replay
print(grant.membership.role, grant.membership.created_at)
print(grant.note) # how the change propagates, see below
listing = client.scopes.list(scope="team:eng") # both filters optional
print(listing.total)
for m in listing.memberships:
print(m.user_id, m.role)
client.scopes.revoke("alice", "team:eng") # NotFoundError if there is no such grant
- A change takes effect on the user's next request. The server resolves a user's read-set from the membership rows on every request; it is not cached in their tokens. A revoked scope is unreadable from the next call on, and a fresh grant is visible from the next call on. Revoking access does not delete anything written into the scope: when the data itself must go, pair the revocation with a deletion.
- Only
team:andproject:scopes can be granted.user:is personal and implicit for its own user (granting someone else's would hand over their private memory) andorg:is already implicit for every member. Both raiseScopeNotDelegableErrorbefore any request; a malformed scope id raisesInvalidScopeError. - Granting to a user who has not logged in yet is fine. A user is just a string in a token, so the row sits inert until someone authenticates as that user in your org — onboarding before first login is a normal flow, not a workaround.
- Replays are idempotent. Re-granting returns the existing row with
created=False; passing a differentroleupdates it in place. - Implicit scopes are not rows, so
list()never shows them — an empty result does not mean the user sees nothing.
Platform operators have the same three calls across orgs at client.admin.scopes.* (explicit org_id=,
any scope kind); a normal customer key gets 403 there.
Deletion
client.deletions erases an org's data across every tier in one request and returns an auditable
receipt. It needs a non-impersonated org-admin API key — role admin (or *), on a client built
without user_id=. A per-user token inherits the key's roles but is still refused (403).
receipt = client.deletions.delete_user_scope("alice", reason="GDPR erasure request")
print(receipt.status) # "completed" | "partial" (also .completed / .partial)
print(receipt.purge_after) # when the retention window elapses
print(receipt.tiers.helixdb.deleted, receipt.tiers.helixdb.refused)
print(receipt.tiers.active_memory.fragments_deleted)
print(receipt.tiers.auth_db.usage_events_retained)
for gap in receipt.not_covered:
print("not covered:", gap.tier, "—", gap.reason)
# Re-read the receipt later
receipt = client.deletions.get(receipt.request_id)
Targets:
client.deletions.delete_user_scope("alice") # one user's personal scope (grants kept)
client.deletions.delete_scope("team:eng") # everything stamped with a scope
client.deletions.delete_user("alice") # a user: personal data, active memory, grants
client.deletions.delete_memory("node-7", owner_user_id="alice") # one memory node; owner is REQUIRED
client.deletions.create(kind="memory", value="node-7", owner_user_id="alice") # general form
delete_memory requires owner_user_id — the id of the user who owns the node. The server does not
default it to the caller; an empty value raises ValueError before any request.
delete_user removes the user's personal-scope long-term memory only: what they wrote into team: /
project: scopes stays (use delete_scope for shared data). Their active-memory data is removed across
all scopes and their scope memberships are removed; usage/billing and episode-registry rows are retained in
soft mode. delete_user_scope leaves scope grants alone (tex.scopes.revoke removes one).
Every method takes reason= and idempotency_key= (replaying a key returns the first receipt instead of
re-running). A malformed scope id raises InvalidScopeError before any request.
What a deletion actually does
- Soft by default, and soft is the only mode an org owner may ask for. The target is tombstoned
across the graph engine, active memory and the auth DB: it disappears from recall, search and profile
immediately. It is not yet destroyed;
receipt.purge_aftersays when it becomes eligible for the hard purge. - Purge is platform-admin only.
mode="purge"onclient.deletions.*raisesPurgeNotPermittedErrorbefore any request. An org owner can make their data unreachable and start the clock; irreversible destruction is a platform operation, so a compromised org key cannot destroy anything on the spot. - Ingestion records and raw S3 files are not covered yet. The ingestion service's documents and jobs
and the raw uploaded objects in object storage are untouched; a follow-up phase owns them. Every receipt
lists them under
not_covered. - The graph engine tombstones; nothing reclaims the space yet. HelixDB exposes only a soft delete, so
even a purge leaves the tombstoned nodes on disk (
helixdb_hard_deleteinnot_covered). They are unreadable, but no job currently reclaims their bytes. Its delete is owner-only, so nodes it refuses are counted intiers.helixdb.refused(the receipt goespartial) rather than raising, and nodes with no scope stamp are left alone (skipped_unscoped) so a shared-scope deletion cannot erase someone's personal data. - Billing survives a soft deletion. Usage events are retained (
tiers.auth_db.usage_events_retained) and removed only by a purge.
Platform-admin surface
client.admin.deletions.* wraps /admin/deletions. These routes are for Tex operators — a normal
customer key gets 403. They take a required org_id= and may ask for mode="purge".
client.admin.deletions.delete_user("bob", org_id="org1", mode="purge")
client.admin.deletions.get("del_abc123") # any org's receipt
# Hard-delete everything past its retention window (point a cron at this)
result = client.admin.deletions.run_purges(limit=50)
print(result.purged, len(result.receipts))
Error Handling
The SDK provides a detailed error hierarchy:
TexError
APIError
APIConnectionError
APITimeoutError
APIStatusError
BadRequestError (400)
AuthenticationError (401)
PermissionDeniedError (403)
NotFoundError (404)
ConflictError (409)
UnprocessableEntityError (422)
RateLimitError (429)
InternalServerError (500+)
APIResponseValidationError
from tex import Tex, NotFoundError, AuthenticationError, APIStatusError
client = Tex(api_key="sk-...", base_url="http://localhost:8000")
try:
doc = client.documents.get("nonexistent")
except NotFoundError as e:
print(f"Not found: {e}")
except AuthenticationError as e:
print(f"Auth failed: {e}")
except APIStatusError as e:
print(f"HTTP {e.status_code}: {e.message}")
print(f"Code: {e.code} Request ID: {e.request_id} Retryable: {e.retryable}")
e.messageis the server's own reason (e.g.Requires one of roles: admin, developer), andstr(e)isHTTP <status>: <reason> (request_id=...).e.codeis the server's machine-readable code when it sends one (e.g.scope_not_visible), elseNone.e.request_ididentifies the request in the server's logs; quote it when reporting a problem.e.retryableis the server's verdict on whether repeating the call can help (Noneif it gave none). The SDK retries 408/429/5xx on its own (reads, and writes that provably never reached the server), but never when the server saysretryable: false. A daily quota (RateLimitErrorwithcode == "quota_exceeded") isretryable: false: it resets at 00:00 UTC.- Client-side checks raise before any request:
InvalidScopeError,ScopeNotDelegableError,InvalidInactivityTimeoutError,InvalidPreferenceError,PurgeNotPermittedError(allValueErrors).
Active memory inactivity window
Conversation turns stored with conversations.remember land in short-term active memory first. An
active-memory episode (one per session_id) is closed and consolidated into long-term memory once it has been
idle for its inactivity window — 30 minutes by default. Tune it to the use case: a support chat can close
after a few minutes, a long-running agent task may need hours.
from tex import Tex
turns = [{"role": "user", "text": "My order arrived damaged.", "timestamp": "2026-10-14T09:00:00Z"}]
# Per conversation (the episode keeps this window for later writes)
client.conversations.remember(turns, session_id="ticket-981", inactivity_timeout_seconds=2 * 60 * 60)
# Client-wide default for every write that does not pass its own
client = Tex(api_key="sk-...", active_inactivity_timeout_seconds=10 * 60)
- Resolution: per-call value → client default → org default (set by an org admin with
PATCH /me/settings{"active_memory_inactivity_seconds": 3600}) → 30 minutes. - Bounds: 300–86400 seconds (5 minutes to 24 hours). Invalid values raise
InvalidInactivityTimeoutErrorbefore any request. memories.addandmemories.update_memoryaccept the sameinactivity_timeout_seconds.
Configuration
Use TexConfig and TexEndpoints for advanced configuration:
from tex import Tex, TexConfig, TexEndpoints
config = TexConfig(
base_url="https://api.example.com",
api_key="sk-...",
timeout_s=30.0,
max_retries=3,
http2=True,
active_inactivity_timeout_seconds=3600, # optional; see "Active memory inactivity window"
endpoints=TexEndpoints(
db_query="/custom/db/query",
db_schema="/custom/db/schema",
),
)
client = Tex(config=config)
Default timeout is 60 seconds. Default max retries is 2.
Migration from SuperMemory
| SuperMemory | Tex SDK |
|---|---|
client.add(content) |
client.add(content) or client.documents.add(content) |
client.documents.get(id) |
client.documents.get(id) |
client.documents.update(id) |
client.documents.update(id) |
client.documents.delete(id) |
client.documents.delete(id) |
client.documents.list() |
client.documents.list() |
client.documents.batch_add(docs) |
client.documents.batch_add(docs) |
client.search.execute(q) |
client.search.execute(q) or client.search.documents(q) |
client.search.memories(q) |
client.search.memories(q) |
client.memories.add(memories) |
client.memories.add(memories) |
client.memories.forget(tag) |
client.memories.forget(tag) |
client.profile(tag) |
client.profile(tag) |
Tex extends SuperMemory with additional resources: nlq, episodes, preferences, db, auth, jobs,
usage, scopes, and deletions (plus the platform-admin-only admin.scopes / admin.deletions).
HTTP/2
HTTP/2 is enabled by default. If the h2 package is not installed, the SDK falls back to HTTP/1.1 automatically. To install with HTTP/2 support:
pip install "tex-sdk[http2]"
To explicitly disable HTTP/2:
from tex import Tex
client = Tex(api_key="sk-...", base_url="http://localhost:8000", http2=False)
Metadata
Release files for tex-sdk 1.5.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tex_sdk-1.5.1.tar.gz | 93.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tex_sdk-1.5.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 157.7 kB
Release files / tex_sdk-1.5.1.tar.gz
| Download URL | tex_sdk-1.5.1.tar.gz |
|---|---|
| Size | 93.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9cc815764f954ee49b94478a40bebc28503cd84dc7be47a3bb20040ad8a4d8a7
|
|
BLAKE2b-256 checksum How to use checksums |
bb055997b3e7d247da1193f35fa7f126d592f88100fc8f9bb7b168dd69746755
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.5
|
Release files / tex_sdk-1.5.1-py3-none-any.whl
| Download URL | tex_sdk-1.5.1-py3-none-any.whl |
|---|---|
| Size | 64.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0ab8808c7cca9a273a14306d9ee0ac6a972313c84e956aef2845943a2575baf6
|
|
BLAKE2b-256 checksum How to use checksums |
e1ead78143d162362c193d60ab37c2617cf92f7121ea4e605b524eee5ff1b970
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.5
|