Python SDK
twinmemory for Python - sync and async clients, typed results, retries, and a client that fails open when memory is down.
twinmemory is the Python client. It is on PyPI Soon: this page describes version 0.2.0, and until it is
published, plain HTTP does the same job.
pip install twinmemoryPython 3.10 or newer. One dependency: httpx.
Quickstart
from twinmemory import Memory
memory = Memory() # reads TWIN_MEMORY_API_KEY
dana = memory.user("dana") # your own id for one of your end users
dana.add("I moved to Lisbon for the job at Acme.", role="user", session="onboarding")
dana.add({"id": "diet", "kind": "note", "text": "Vegetarian, allergic to peanuts."}, wait=True)
result = dana.search("Where does Dana live?")
print(result.context()) # ready to paste into a promptMake a key in the console, under API keys. A key belongs to one project; each end user you name with
memory.user(...) has a memory of their own inside it. See End users.
| Setting | Argument | Environment variable | Default |
|---|---|---|---|
| API key | api_key | TWIN_MEMORY_API_KEY | required |
| Base URL | base_url | TWIN_MEMORY_BASE_URL | https://api.twinmemory.dev |
| Timeout | timeout | 60 seconds | |
| Retries | max_retries | 2 | |
| Fail open | fail_open | False |
For a stack on your machine, set TWIN_MEMORY_BASE_URL=http://localhost:8210.
Adding
add takes a string, an item dict, or a list of either. Keyword fields apply to every item that does not set
its own.
dana.add(
[
{"text": "Can you book the same hotel as last time?", "role": "user"},
{"text": "Booked the Memmo Alfama again for 3-5 May.", "role": "assistant"},
],
session="trip-may",
)
dana.add("Moving house on 1 December.", kind="note", expires="2026-12-31")The fields are those of Add: text (required), kind, id, session, role, author,
title, about, at, expires and metadata. An add is accepted at once and searchable a moment later;
wait=True returns once it is searchable. Lists longer than 500 items go in batches, and every add carries an
Idempotency-Key, so a retried request never saves an item twice.
Searching
result = dana.search("What should I avoid cooking for Dana?")
result.known # what is true now, as lines
result.facts # dated facts, each with the passage it came from
result.hits # whole passages, with their addresses
result.held # False when memory says it does not hold the answer
result.context(max_chars=4000)fast=True skips the model filter: free and quicker, a little less precise. before and after (ISO dates)
and scope (a path prefix) narrow a search. See Search.
Everything else
| Method | Route |
|---|---|
memory.me() | Me |
user.history(subject, attribute=None) | History |
user.latest(k=10, place="", after=, before=) | Latest |
user.grep(pattern, scope=None, limit=50) | Grep |
user.read(path) / user.ls(path) | Read / Ls |
user.around(passage_id, width=2) | Around |
user.overview() | Overview |
user.forget(items=, sessions=, threads=, events=) | Forget |
user.correct(record_id, replacement=None) | Correct |
user.versions(item_id) | Versions |
user.status(ids) | Status |
user.lineage(id) | Lineage |
Every result is a typed object; .raw holds the JSON as the API sent it.
Async
from twinmemory import AsyncMemory
async with AsyncMemory() as memory:
dana = memory.user("dana")
await dana.add("Prefers green tea.")
result = await dana.search("What does Dana drink?", fast=True)Errors
Every error is a TwinMemoryError with status, the API's detail, request_id, and outage: whether it
means memory is unavailable rather than that the request or the key is wrong. See Errors.
| Error | When |
|---|---|
AuthenticationError | 401: no key, or one the service did not issue |
PermissionDeniedError | 403: the key reads, and the call writes |
NotFoundError | 404: nothing was ever added for this end user |
InvalidRequestError | 400, 413, 422: the request itself is wrong |
RateLimitError | 429, after retries |
ServiceUnavailableError | 503, after retries |
APIConnectionError | the service could not be reached, after retries |
APITimeoutError | the service did not answer in time, after retries |
429 and 503 are retried twice, waiting as the Retry-After header says; connection errors the same way.
Failing open
Memory being down should cost a reply its memory, not the reply. With Memory(fail_open=True), an outage -
memory unreachable, timing out, or answering 429 or 5xx after its retries - does not raise:
searchanswers an empty result withfailed=True;addsaves nothing and answersfailed=True;- each logs a warning on the
twinmemorylogger.
A wrong key or a wrong request (401, 403, 400) still raises: that is a mistake to fix, not an outage to ride
out. Pair it with a short timeout where a reply cannot wait.
Memory(integration="langgraph") adds a word to the User-Agent, so the console's request logs show where
requests came from.
LangGraph
uvx twinmemory-cli init langgraph writes memory into a LangGraph project as code you own, using this client.
See LangGraph.
Until it is on PyPI
The API is plain HTTP and JSON, so a few lines over requests do the same job now:
import os
import requests
BASE = "https://api.twinmemory.dev/v1/memory"
def twin(user: str, route: str, body: dict) -> dict | None:
r = requests.post(
f"{BASE}/{route}",
json=body,
timeout=60,
headers={
"Authorization": f"Bearer {os.environ['TWIN_MEMORY_API_KEY']}",
"X-Twin-Account": user,
},
)
if r.status_code == 404 and route != "correct":
return None # nothing was ever written for this end user
r.raise_for_status()
return r.json()
twin("dana", "add", {"items": [{"text": "I moved to Lisbon.", "role": "user"}]})
brief = twin("dana", "search", {"query": "Where does Dana live?"})Every route has the same shape: a POST to /v1/memory/<name> with a JSON body. See the API reference.