twin memory

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

KeyWhere it runsWhat it may do
Read and writeYour backend onlyAdd, forget, correct, read versions, and every read
ReadAn agent that only reads, a search workerSearch, 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.

On this page