Multi-tenant SaaS
Give every user of your product their own memory, kept apart from every other, with one project and two keys.
Your product has many users. Each one gets a memory of their own. Your backend names the user on every
request with X-Twin-Account, and the key decides which organisation and project the request belongs to. No
request can reach a user of another organisation or another project.
Create a project and two keys
In the console, open API keys and create two keys for the project:
- a read and write key, for your backend. It adds, forgets and corrects.
- a read key, for the runtime that gives a model memory tools. It can search and read, and nothing more.
Each key is shown once. Keep them in your secret store, not in source code. See Authentication and keys for what each key may call.
Map your user id to X-Twin-Account
Use the id your app already has for the user, such as user_42. It can hold letters, digits, ., _ and
-, and should start with a letter or a digit.
The service joins your organisation, your project and this id into one internal account id of at most 64 characters. With the longest project slug, an id of up to 35 characters always fits. A shorter project slug leaves more room. End users and tenancy has the exact rule.
If your ids are longer, or hold other characters, send a stable digest of them instead, and keep the mapping on your side.
Add on each event, from your backend
When the user says or writes something worth keeping, your backend adds it. Give every item an id you can
find again, so a retry or an edit never makes a second copy.
curl https://api.twinmemory.dev/v1/memory/add \
-H "Authorization: Bearer $TWIN_MEMORY_API_KEY" \
-H "X-Twin-Account: user_42" \
-H "Content-Type: application/json" \
-d '{
"items": [{
"id": "ticket-981-msg-1",
"session": "ticket-981",
"role": "user",
"author": {"id": "user_42", "name": "Alice Chen"},
"text": "The export to CSV fails every time I pick more than one project."
}]
}'The answer is 202 Accepted. The item is committed, and it is searchable a moment later.
Search before replying
Before your model answers the user, search that user's memory with their message and hand the result to the model. The chat assistant guide shows the whole loop.
def search(user_id: str, query: str) -> dict:
r = requests.post(
f"{TWIN}/search",
headers={
"Authorization": f"Bearer {os.environ['TWIN_MEMORY_API_KEY']}",
"X-Twin-Account": user_id,
},
json={"query": query},
timeout=30,
)
if r.status_code == 404:
return {"found": 0, "hits": []}
r.raise_for_status()
return r.json()Treat 404 as empty memory
A read for a user nothing was ever written for answers 404, not an empty result. A new user is the common
case, so handle it as "no memory yet" in one place, as the search function above does. The first add for a
user creates their memory.
Erase a user when they ask
To erase part of a user's memory, call forget with the items or sessions to remove. The
answer is a receipt that counts every table before and after.
To erase a user whole, open End users in the console and delete the user. Their memory file, and anything still waiting for them in the queue, is removed.
The erasure requests guide covers both, and what to keep as a record.
Where each key lives
| Key | Where it runs | What it may do |
|---|---|---|
| Read and write | Your backend only | Add, forget, correct, read versions, and every read |
| Read | An agent that only reads, a search worker | Search, grep, read, ls, around, latest, overview, history, status, lineage |
Never put a key in a browser or a mobile app. A key in a client can be copied, and it speaks for every user of the project.
A model that writes
Give the read and write key to a model's tool binding only when you mean the model to save. With that
key, the hosted MCP server also lists memory_add, and the model can add to the user's memory
on its own. With a read key, it cannot.
Why the user is a header
X-Twin-Account is never a field in a request body. A model fills in tool arguments, and a model that could
name the user could name somebody else. The header is set by your code, from your own session, so the model
never chooses whose memory it reads.